diff --git a/docs/api_reference.md b/docs/api_reference.md index 03c648ef..2cbef63a 100644 --- a/docs/api_reference.md +++ b/docs/api_reference.md @@ -61,7 +61,7 @@ cases_funs = get_all_cases(f, cases=cases, prefix=prefix, glob=glob, has_tag=has_tag, filter=filter) # Transform the various functions found -argvalues = get_parametrize_args(cases_funs) +argvalues = get_parametrize_args(host_class_or_module_of_f, cases_funs) ``` **Parameters** @@ -97,7 +97,9 @@ Lists all desired cases for a given `parametrization_target` (a test function or ### `get_parametrize_args` ```python -def get_parametrize_args(cases_funs: List[Callable], +def get_parametrize_args(host_class_or_module: Union[Type, ModuleType], + cases_funs: List[Callable], + debug: bool = False ) -> List[Union[lazy_value, fixture_ref]]: ``` diff --git a/pytest_cases/case_funcs_new.py b/pytest_cases/case_funcs_new.py index f5eea976..8f872e9c 100644 --- a/pytest_cases/case_funcs_new.py +++ b/pytest_cases/case_funcs_new.py @@ -219,5 +219,8 @@ def is_case_function(f, prefix=CASE_PREFIX_FUN, check_prefix=True): return False elif safe_isclass(f): return False + elif hasattr(f, '_pytestcasesgen'): + # a function generated by us. ignore this + return False else: return f.__name__.startswith(prefix) if check_prefix else True diff --git a/pytest_cases/case_parametrizer_new.py b/pytest_cases/case_parametrizer_new.py index 594f558a..ed10cf11 100644 --- a/pytest_cases/case_parametrizer_new.py +++ b/pytest_cases/case_parametrizer_new.py @@ -3,20 +3,22 @@ from functools import partial from importlib import import_module -from inspect import getmembers +from inspect import getmembers, isfunction, ismethod import re from warnings import warn +import makefun + try: from typing import Union, Callable, Iterable, Any, Type, List, Tuple # noqa except ImportError: pass from .common_mini_six import string_types -from .common_others import get_code_first_line, AUTO, AUTO2 +from .common_others import get_code_first_line, AUTO, AUTO2, qname from .common_pytest_marks import copy_pytest_marks, make_marked_parameter_value from .common_pytest_lazy_values import lazy_value -from .common_pytest import safe_isclass, MiniMetafunc +from .common_pytest import safe_isclass, MiniMetafunc, is_fixture, get_fixture_name, inject_host from . import fixture from .case_funcs_new import matches_tag_query, is_case_function, is_case_class, CaseInfo, CASE_PREFIX_FUN @@ -43,6 +45,7 @@ def parametrize_with_cases(argnames, # type: str glob=None, # type: str has_tag=None, # type: Any filter=None, # type: Callable[[Callable], bool] # noqa + debug=False, # type: bool **kwargs ): # type: (...) -> Callable[[Callable], Callable] @@ -68,7 +71,7 @@ def parametrize_with_cases(argnames, # type: str cases_funs = get_all_cases(f, cases=cases, prefix=prefix, glob=glob, has_tag=has_tag, filter=filter) # Transform the various functions found - argvalues = get_parametrize_args(cases_funs, prefix=prefix) + argvalues = get_parametrize_args(host_class_or_module, cases_funs, debug=False) ``` :param argnames: same than in @pytest.mark.parametrize @@ -88,21 +91,29 @@ def parametrize_with_cases(argnames, # type: str decorator on the case function(s) to be selected. :param filter: a callable receiving the case function and returning True or a truth value in case the function needs to be selected. + :param debug: a boolean flag to debug what happens behind the scenes :return: """ - def _apply_parametrization(f): + @inject_host + def _apply_parametrization(f, host_class_or_module): """ execute parametrization of test function or fixture `f` """ # Collect all cases cases_funs = get_all_cases(f, cases=cases, prefix=prefix, glob=glob, has_tag=has_tag, filter=filter) - # Transform the various functions found - argvalues = get_parametrize_args(cases_funs) + # Transform the various case functions found into `lazy_value` (for case functions not requiring fixtures) + # or `fixture_ref` (for case functions requiring fixtures - for them we create associated case fixtures in + # `host_class_or_module`) + argvalues = get_parametrize_args(host_class_or_module, cases_funs, debug=debug) # Finally apply parametrization - note that we need to call the private method so that fixture are created in # the right module (not here) - _parametrize_with_cases = _parametrize_plus(argnames, argvalues, **kwargs) - return _parametrize_with_cases(f) + _parametrize_with_cases, needs_inject = _parametrize_plus(argnames, argvalues, debug=debug, **kwargs) + + if needs_inject: + return _parametrize_with_cases(f, host_class_or_module) + else: + return _parametrize_with_cases(f) return _apply_parametrization @@ -220,7 +231,9 @@ def get_all_cases(parametrization_target, # type: Callable and matches_tag_query(c, has_tag=has_tag, filter=filters)] -def get_parametrize_args(cases_funs, # type: List[Callable] +def get_parametrize_args(host_class_or_module, # type: Union[Type, ModuleType] + cases_funs, # type: List[Callable] + debug=False # type: bool ): # type: (...) -> List[Union[lazy_value, fixture_ref]] """ @@ -228,18 +241,22 @@ def get_parametrize_args(cases_funs, # type: List[Callable] Each case function `case_fun` is transformed into one or several `lazy_value`(s) or a `fixture_ref`: - If `case_fun` requires at least on fixture, a fixture will be created if not yet present, and a `fixture_ref` - will be returned. + will be returned. The fixture will be created in `host_class_or_module` - If `case_fun` is a parametrized case, one `lazy_value` with a partialized version will be created for each parameter combination. - Otherwise, `case_fun` represents a single case: in that case a single `lazy_value` is returned. - :param cases_funs: a list of case functions returned typically by `get_all_cases` + :param host_class_or_module: host of the parametrization target. A class or a module. + :param cases_funs: a list of case functions, returned typically by `get_all_cases` + :param debug: a boolean flag, turn it to True to print debug messages. :return: """ - return [c for _f in cases_funs for c in case_to_argvalues(_f)] + return [c for _f in cases_funs for c in case_to_argvalues(host_class_or_module, _f, debug)] -def case_to_argvalues(case_fun, # type: Callable +def case_to_argvalues(host_class_or_module, # type: Union[Type, ModuleType] + case_fun, # type: Callable + debug=False # type: bool ): # type: (...) -> Tuple[lazy_value] """Transform a single case into one or several `lazy_value`(s) or a `fixture_ref` to be used in `@parametrize` @@ -265,38 +282,145 @@ def case_to_argvalues(case_fun, # type: Callable if not meta.requires_fixtures: if not meta.is_parametrized: # single unparametrized case function + if debug: + case_fun_str = qname(case_fun.func if isinstance(case_fun, partial) else case_fun) + print("Case function %s > 1 lazy_value() with id %s and marks %s" % (case_fun_str, case_id, case_marks)) return (lazy_value(case_fun, id=case_id, marks=case_marks),) else: # parametrized. create one version of the callable for each parametrized call + if debug: + case_fun_str = qname(case_fun.func if isinstance(case_fun, partial) else case_fun) + print("Case function %s > tuple of lazy_value() with ids %s and marks %s" + % (case_fun_str, ["%s-%s" % (case_id, c.id) for c in meta._calls], [c.marks for c in meta._calls])) return tuple(lazy_value(partial(case_fun, **c.funcargs), id="%s-%s" % (case_id, c.id), marks=c.marks) for c in meta._calls) else: - # at least a required fixture: create a fixture - # unwrap any partial that would have been created by us because the fixture was in a class - if isinstance(case_fun, partial): - host_cls = case_fun.host_class - case_fun = case_fun.func - else: - host_cls = None + # at least a required fixture: + # create or reuse a fixture in the host (pytest collector: module or class) of the parametrization target + fix_name = get_or_create_case_fixture(case_id, case_fun, host_class_or_module, debug) - host_module = import_module(case_fun.__module__) - - # create a new fixture and place it on the host - # we have to create a unique fixture name if the fixture already exists. - def name_changer(name, i): - return name + '_' * i - new_fix_name = check_name_available(host_cls or host_module, name=case_id, if_name_exists=CHANGE, - name_changer=name_changer) # if meta.is_parametrized: # nothing to do, the parametrization marks are already there - new_fix = fixture(name=new_fix_name)(case_fun) - setattr(host_cls or host_module, new_fix_name, new_fix) - # now reference the new or existing fixture - argvalues_tuple = (fixture_ref(new_fix_name),) + # reference that case fixture + argvalues_tuple = (fixture_ref(fix_name),) + if debug: + case_fun_str = qname(case_fun.func if isinstance(case_fun, partial) else case_fun) + print("Case function %s > fixture_ref(%r) with marks %s" % (case_fun_str, fix_name, case_marks)) return make_marked_parameter_value(argvalues_tuple, marks=case_marks) if case_marks else argvalues_tuple +def get_or_create_case_fixture(case_id, # type: str + case_fun, # type: Callable + target_host, # type: Union[Type, ModuleType] + debug=False # type: bool + ): + # type: (...) -> str + """ + When case functions require fixtures, we want to rely on pytest to inject everything. Therefore + we create a fixture wrapping the case function. Since a case function may not be located in the same place + than the test/fixture requiring it (decorated with @parametrize_with_cases), we create that fixture in the + appropriate module/class (the host of the test/fixture function). + + :param case_id: + :param case_fun: + :param host_class_or_module: + :param debug: + :return: the newly created fixture name + """ + if is_fixture(case_fun): + raise ValueError("A case function can not be decorated as a `@fixture`. This seems to be the case for" + " %s. If you did not decorate it but still see this error, please report this issue" + % case_fun) + + # source + case_in_class = isinstance(case_fun, partial) and hasattr(case_fun, 'host_class') + true_case_func = case_fun.func if case_in_class else case_fun + # case_host = case_fun.host_class if case_in_class else import_module(case_fun.__module__) + + # for checks + orig_name = true_case_func.__name__ + orig_case = true_case_func + + # destination + target_in_class = safe_isclass(target_host) + fix_cases_dct = _get_fixture_cases(target_host) # get our "storage unit" in this module + + # shortcut if the case fixture is already known/registered in target host + try: + fix_name = fix_cases_dct[true_case_func] + if debug: + print("Case function %s > Reusing fixture %r" % (qname(true_case_func), fix_name)) + return fix_name + except KeyError: + pass + + # not yet known there. Create a new symbol in the target host : + # we need a "free" fixture name, and a "free" symbol name + existing_fixture_names = [] + for n, symb in getmembers(target_host, lambda f: isfunction(f) or ismethod(f)): + if is_fixture(symb): + existing_fixture_names.append(get_fixture_name(symb)) + + def name_changer(name, i): + return name + '_' * i + + # start with name = case_id and find a name that does not exist + fix_name = check_name_available(target_host, extra_forbidden_names=existing_fixture_names, name=case_id, + if_name_exists=CHANGE, name_changer=name_changer) + + if debug: + print("Case function %s > Creating fixture %r in %s" % (qname(true_case_func), fix_name, target_host)) + + def funcopy(f): + # apparently it is not possible to create an actual copy with copy() ! + return makefun.partial(f) + + if case_in_class: + if target_in_class: + # both in class: direct copy of the non-partialized version + case_fun = funcopy(case_fun.func) + else: + # case in class and target in module: use the already existing partialized version + case_fun = funcopy(case_fun) + else: + if target_in_class: + # case in module and target in class: create a static method + case_fun = staticmethod(case_fun) + else: + # none in class: direct copy + case_fun = funcopy(case_fun) + + # create a new fixture from a copy of the case function, and place it on the target host + new_fix = fixture(name=fix_name)(case_fun) + # mark as generated by pytest-cases so that we skip it during cases collection + new_fix._pytestcasesgen = True + setattr(target_host, fix_name, new_fix) + + # remember it for next time + fix_cases_dct[true_case_func] = fix_name + + # check that we did not touch the original case + assert not is_fixture(orig_case) + assert orig_case.__name__ == orig_name + + return fix_name + + +def _get_fixture_cases(module # type: ModuleType + ): + """ + Returns our 'storage unit' in a module, used to remember the fixtures created from case functions. + That way we can reuse fixtures already created for cases, in a given module/class. + """ + try: + cache = module._fixture_cases + except AttributeError: + cache = dict() + module._fixture_cases = cache + return cache + + def import_default_cases_module(f, alt_name=False): """ Implements the `module=AUTO` behaviour of `@parameterize_cases`: based on the decorated test function `f`, diff --git a/pytest_cases/common_mini_six.py b/pytest_cases/common_mini_six.py index 09049345..a05b6b48 100644 --- a/pytest_cases/common_mini_six.py +++ b/pytest_cases/common_mini_six.py @@ -1,6 +1,6 @@ import sys -PY3 = sys.version_info[0] == 3 +PY3 = sys.version_info[0] >= 3 PY34 = sys.version_info[0:2] >= (3, 4) if PY3: @@ -9,6 +9,42 @@ string_types = basestring, +# if PY3: +# def reraise(tp, value, tb=None): +# try: +# if value is None: +# value = tp() +# else: +# # HACK to fix bug +# value = tp(*value) +# if value.__traceback__ is not tb: +# raise value.with_traceback(tb) +# raise value +# finally: +# value = None +# tb = None +# +# else: +# def exec_(_code_, _globs_=None, _locs_=None): +# """Execute code in a namespace.""" +# if _globs_ is None: +# frame = sys._getframe(1) +# _globs_ = frame.f_globals +# if _locs_ is None: +# _locs_ = frame.f_locals +# del frame +# elif _locs_ is None: +# _locs_ = _globs_ +# exec("""exec _code_ in _globs_, _locs_""") +# +# exec_("""def reraise(tp, value, tb=None): +# try: +# raise tp, value, tb +# finally: +# tb = None +# """) + + def with_metaclass(meta, *bases): """Create a base class with a metaclass.""" # This requires a bit of explanation: the basic idea is to make a dummy diff --git a/pytest_cases/common_others.py b/pytest_cases/common_others.py index 0be4f51c..0d1ee390 100644 --- a/pytest_cases/common_others.py +++ b/pytest_cases/common_others.py @@ -1,3 +1,6 @@ +import functools +import inspect +from importlib import import_module from inspect import findsource import re @@ -6,7 +9,7 @@ except ImportError: pass -from .common_mini_six import string_types +from .common_mini_six import string_types, PY3 def get_code_first_line(f): @@ -204,3 +207,56 @@ def __exit__(self, exc_type, exc_val, exc_tb): AUTO2 = object() """Marker that alternate automatic defaults""" + + +def get_function_host(func): + """ + Returns the module or class where func is defined. Approximate method based on qname but "good enough" + + :param func: + :return: + """ + host = get_class_that_defined_method(func) + if host is None: + host = import_module(func.__module__) + # assert func in host + + return host + + +def get_class_that_defined_method(meth): + """ Adapted from https://stackoverflow.com/a/25959545/7262247 , to support python 2 too """ + if isinstance(meth, functools.partial): + return get_class_that_defined_method(meth.func) + + if inspect.ismethod(meth) or (inspect.isbuiltin(meth) and getattr(meth, '__self__', None) is not None + and getattr(meth.__self__, '__class__', None)): + for cls in inspect.getmro(meth.__self__.__class__): + if meth.__name__ in cls.__dict__: + return cls + meth = getattr(meth, '__func__', meth) # fallback to __qualname__ parsing + + if inspect.isfunction(meth): + cls = getattr(inspect.getmodule(meth), + qname(meth).split('.', 1)[0].rsplit('.', 1)[0], + None) + if isinstance(cls, type): + return cls + + return getattr(meth, '__objclass__', None) # handle special descriptor objects + + +if PY3: + def qname(func): + return func.__qualname__ +else: + def qname(func): + """'good enough' python 2 implementation of __qualname__""" + try: + hostclass = func.im_class + except AttributeError: + # no host class + return "%s.%s" % (func.__module__, func.__name__) + else: + # host class: recurse (note that in python 2 nested classes do not have a way to know their parent class) + return "%s.%s" % (qname(hostclass), func.__name__) diff --git a/pytest_cases/common_pytest.py b/pytest_cases/common_pytest.py index e0f16fca..61c32302 100644 --- a/pytest_cases/common_pytest.py +++ b/pytest_cases/common_pytest.py @@ -17,6 +17,7 @@ from _pytest.python import Metafunc from .common_mini_six import string_types +from .common_others import get_function_host from .common_pytest_marks import make_marked_parameter_value, get_param_argnames_as_list, has_pytest_param, \ get_pytest_parametrize_marks from .common_pytest_lazy_values import is_lazy_value @@ -643,3 +644,78 @@ def _cart_product_pytest(argnames_lists, argvalues): result.append((x_marks_lst, x_value_lst)) return result + + +def inject_host(apply_decorator): + """ + A decorator for function with signature `apply_decorator(f, host)`, in order to inject 'host', the host of f. + + Since it is not entirely feasible to detect the host in python, my first implementation was a bit complex: it was + returning an object with custom implementation of __call__ and __get__ methods, both reacting when pytest collection + happens. + + That was very complex. Now we rely on an approximate but good enough alternative with `get_function_host` + + :param apply_decorator: + :return: + """ + # class _apply_decorator_with_host_tracking(object): + # def __init__(self, _target): + # # This is called when the decorator is applied on the target. Remember the target and result of paramz + # self._target = _target + # self.__wrapped__ = None + # + # def __get__(self, obj, type_=None): + # """ + # When the decorated test function or fixture sits in a cl + # :param obj: + # :param type_: + # :return: + # """ + # # We now know that the parametrized function/fixture self._target sits in obj (a class or a module) + # # We can therefore apply our parametrization accordingly (we need a reference to this host container in + # # order to store fixtures there) + # if self.__wrapped__ is None: + # self.__wrapped__ = 1 # means 'pending', to protect against infinite recursion + # try: + # self.__wrapped__ = apply_decorator(self._target, obj) + # except Exception as e: + # traceback = sys.exc_info()[2] + # reraise(BaseException, e.args, traceback) + # + # # path, lineno = get_fslocation_from_item(self) + # # warn_explicit( + # # "Error parametrizing function %s : [%s] %s" % (self._target, e.__class__, e), + # # category=None, + # # filename=str(path), + # # lineno=lineno + 1 if lineno is not None else None, + # # ) + # # + # # @wraps(self._target) + # # def _exc_raiser(*args, **kwargs): + # # raise e + # # # remove this metadata otherwise pytest will unpack it + # # del _exc_raiser.__wrapped__ + # # self.__wrapped__ = _exc_raiser + # + # return self.__wrapped__ + # + # def __getattribute__(self, item): + # if item == '__call__': + # # direct call means that the parametrized function sits in a module. import it + # host_module = import_module(self._target.__module__) + # + # # next time the __call__ attribute will be set so callable() will work + # self.__call__ = self.__get__(host_module) + # return self.__call__ + # else: + # return object.__getattribute__(self, item) + # + # return _apply_decorator_with_host_tracking + + def apply(test_or_fixture_func): + # approximate but far less complex to debug than above ! + container = get_function_host(test_or_fixture_func) + return apply_decorator(test_or_fixture_func, container) + + return apply diff --git a/pytest_cases/fixture__creation.py b/pytest_cases/fixture__creation.py index bddc2694..f43aa3c2 100644 --- a/pytest_cases/fixture__creation.py +++ b/pytest_cases/fixture__creation.py @@ -35,15 +35,21 @@ def check_name_available(module, if_name_exists=RAISE, # type: int name_changer=None, # type: Callable caller=None, # type: Callable[[Any], Any] + extra_forbidden_names=() # type: Iterable[str] ): """ - Routine to - - :param module: - :param name: - :param if_name_exists: - :param name_changer: - :param caller: + Routine to check that a name is not already in dir(module) + extra_forbidden_names. + The `if_name_exists` argument allows users to specify what happens if a name exists already. + + `if_name_exists=CHANGE` allows users to ask for a new non-conflicting name to be found and returned. + + :param module: a module or a class. dir(module) + extra_forbidden_names is used as a reference of forbidden names + :param name: proposed name, to check against existent names in module + :param if_name_exists: policy to apply if name already exists in dir(module) + extra_forbidden_names + :param name_changer: an optional custom name changer function for new names to be generated + :param caller: for warning / error messages. Something identifying the caller + :param extra_forbidden_names: a reference list of additional forbidden names that can be provided, in addition to + dir(module) :return: a name that might be different if policy was CHANGE """ if name_changer is None: @@ -51,7 +57,9 @@ def check_name_available(module, def name_changer(name, i): return name + '_%s' % i - if name in dir(module): + ref_list = dir(module) + list(extra_forbidden_names) + + if name in ref_list: if caller is None: caller = '' @@ -66,7 +74,7 @@ def name_changer(name, i): # find a non-used name in that module i = 1 name2 = name_changer(name, i) - while name2 in dir(module): + while name2 in ref_list: i += 1 name2 = name_changer(name, i) diff --git a/pytest_cases/fixture_core1_unions.py b/pytest_cases/fixture_core1_unions.py index 62000d17..be83bea8 100644 --- a/pytest_cases/fixture_core1_unions.py +++ b/pytest_cases/fixture_core1_unions.py @@ -282,7 +282,7 @@ def fixture_union(name, # type: str return union_fix -def _fixture_union(caller_module, +def _fixture_union(fixtures_dest, name, # type: str fix_alternatives, # type: Sequence[UnionFixtureAlternative] unique_fix_alt_names, # type: List[str] @@ -298,7 +298,7 @@ def _fixture_union(caller_module, The "alternatives" have to be created beforehand, by the caller. This allows `fixture_union` and `parametrize_plus` to use the same implementation while `parametrize_plus` uses customized "alternatives" containing more information. - :param caller_module: + :param fixtures_dest: :param name: :param fix_alternatives: :param unique_fix_alt_names: @@ -340,8 +340,8 @@ def _new_fixture(request, **all_fixtures): new_union_fix = _make_fix(_new_fixture) # Dynamically add fixture to caller's module as explained in https://github.com/pytest-dev/pytest/issues/2424 - check_name_available(caller_module, name, if_name_exists=WARN, caller=caller) - setattr(caller_module, name, new_union_fix) + check_name_available(fixtures_dest, name, if_name_exists=WARN, caller=caller) + setattr(fixtures_dest, name, new_union_fix) return new_union_fix @@ -390,18 +390,19 @@ def test_function(a, b): :return: the created fixtures. """ # get caller module to create the symbols + # todo what if this is called in a class ? caller_module = get_caller_module() return _unpack_fixture(caller_module, argnames, fixture, hook=hook) -def _unpack_fixture(caller_module, # type: ModuleType +def _unpack_fixture(fixtures_dest, # type: ModuleType argnames, # type: Union[str, Iterable[str]] fixture, # type: Union[str, Callable] hook # type: Callable[[Callable], Callable] ): """ - :param caller_module: + :param fixtures_dest: :param argnames: :param fixture: :param hook: an optional hook to apply to each fixture function that is created during this call. The hook function @@ -446,8 +447,8 @@ def _param_fixture(request, **kwargs): fix = _create_fixture(value_idx) # add to module - check_name_available(caller_module, argname, if_name_exists=WARN, caller=unpack_fixture) - setattr(caller_module, argname, fix) + check_name_available(fixtures_dest, argname, if_name_exists=WARN, caller=unpack_fixture) + setattr(fixtures_dest, argname, fix) # collect to return the whole list eventually created_fixtures.append(fix) diff --git a/pytest_cases/fixture_core2.py b/pytest_cases/fixture_core2.py index 686a99b4..a1747dae 100644 --- a/pytest_cases/fixture_core2.py +++ b/pytest_cases/fixture_core2.py @@ -74,13 +74,14 @@ def test_uses_param(my_parameter, fixture_uses_param): elif len(argname.replace(' ', '')) == 0: raise ValueError("empty argname") + # todo what if this is called in a class ? caller_module = get_caller_module() return _create_param_fixture(caller_module, argname, argvalues, autouse=autouse, ids=ids, scope=scope, hook=hook, debug=debug, **kwargs) -def _create_param_fixture(caller_module, +def _create_param_fixture(fixtures_dest, argname, # type: str argvalues, # type: Sequence[Any] autouse=False, # type: bool @@ -120,8 +121,8 @@ def __param_fixture(request): hook=hook, **kwargs)(__param_fixture) # Dynamically add fixture to caller's module as explained in https://github.com/pytest-dev/pytest/issues/2424 - check_name_available(caller_module, argname, if_name_exists=WARN, caller=param_fixture) - setattr(caller_module, argname, fix) + check_name_available(fixtures_dest, argname, if_name_exists=WARN, caller=param_fixture) + setattr(fixtures_dest, argname, fix) return fix @@ -183,7 +184,7 @@ def test_uses_param2(arg1, arg2, fixture_uses_param2): hook=hook, debug=debug, **kwargs) -def _create_params_fixture(caller_module, +def _create_params_fixture(fixtures_dest, argnames_lst, # type: Sequence[str] argvalues, # type: Sequence[Any] autouse=False, # type: bool @@ -200,7 +201,7 @@ def _create_params_fixture(caller_module, root_fixture_name = "%s__param_fixtures_root" % ('_'.join(sorted(argnames_lst))) # Dynamically add fixture to caller's module as explained in https://github.com/pytest-dev/pytest/issues/2424 - root_fixture_name = check_name_available(caller_module, root_fixture_name, if_name_exists=CHANGE, + root_fixture_name = check_name_available(fixtures_dest, root_fixture_name, if_name_exists=CHANGE, caller=param_fixtures) if debug: @@ -213,7 +214,7 @@ def _root_fixture(**_kwargs): return tuple(_kwargs[k] for k in argnames_lst) # Override once again the symbol with the correct contents - setattr(caller_module, root_fixture_name, _root_fixture) + setattr(fixtures_dest, root_fixture_name, _root_fixture) # finally create the sub-fixtures for param_idx, argname in enumerate(argnames_lst): @@ -237,8 +238,8 @@ def _param_fixture(**_kwargs): fix = _create_fixture(param_idx) # add to module - check_name_available(caller_module, argname, if_name_exists=WARN, caller=param_fixtures) - setattr(caller_module, argname, fix) + check_name_available(fixtures_dest, argname, if_name_exists=WARN, caller=param_fixtures) + setattr(fixtures_dest, argname, fix) # collect to return the whole list eventually created_fixtures.append(fix) @@ -305,6 +306,8 @@ def fixture_plus(scope="function", # type: str `pytest-harvest` as a hook in order to save all such created fixtures in the fixture store. :param kwargs: other keyword arguments for `@pytest.fixture` """ + # todo what if this is called in a class ? + # the offset is 3 because of @function_decorator (decopatch library) return _decorate_fixture_plus(fixture_func, scope=scope, autouse=autouse, name=name, unpack_into=unpack_into, hook=hook, _caller_module_offset_when_unpack=3, **kwargs) diff --git a/pytest_cases/fixture_parametrize_plus.py b/pytest_cases/fixture_parametrize_plus.py index 7ea54afc..d4d226dc 100644 --- a/pytest_cases/fixture_parametrize_plus.py +++ b/pytest_cases/fixture_parametrize_plus.py @@ -22,15 +22,15 @@ from .common_pytest_marks import has_pytest_param, get_param_argnames_as_list from .common_pytest_lazy_values import is_lazy_value, is_lazy, get_lazy_args from .common_pytest import get_fixture_name, remove_duplicates, mini_idvalset, is_marked_parameter_value, \ - extract_parameterset_info, ParameterSet, cart_product_pytest, mini_idval + extract_parameterset_info, ParameterSet, cart_product_pytest, mini_idval, inject_host -from .fixture__creation import check_name_available, CHANGE, WARN, get_caller_module +from .fixture__creation import check_name_available, CHANGE, WARN from .fixture_core1_unions import InvalidParamsList, NOT_USED, UnionFixtureAlternative, _make_fixture_union, \ _make_unpack_fixture from .fixture_core2 import _create_param_fixture, fixture_plus -def _fixture_product(caller_module, +def _fixture_product(fixtures_dest, name, # type: str fixtures_or_values, fixture_positions, @@ -44,7 +44,7 @@ def _fixture_product(caller_module, """ Internal implementation for fixture products created by pytest parametrize plus. - :param caller_module: + :param fixtures_dest: :param name: :param fixtures_or_values: :param fixture_positions: @@ -103,12 +103,12 @@ def _new_fixture(**all_fixtures): fix = f_decorator(_new_fixture) # Dynamically add fixture to caller's module as explained in https://github.com/pytest-dev/pytest/issues/2424 - check_name_available(caller_module, name, if_name_exists=WARN, caller=caller) - setattr(caller_module, name, fix) + check_name_available(fixtures_dest, name, if_name_exists=WARN, caller=caller) + setattr(fixtures_dest, name, fix) # if unpacking is requested, do it here if unpack_into is not None: - _make_unpack_fixture(caller_module, argnames=unpack_into, fixture=name, hook=hook) + _make_unpack_fixture(fixtures_dest, argnames=unpack_into, fixture=name, hook=hook) return fix @@ -141,7 +141,7 @@ def pytest_parametrize_plus(*args, **kwargs): warn("`pytest_parametrize_plus` is deprecated. Please use the new alias `parametrize_plus`. " "See https://github.com/pytest-dev/pytest/issues/6475", category=DeprecationWarning, stacklevel=2) - return _parametrize_plus(*args, **kwargs) + return parametrize_plus(*args, **kwargs) class ParamAlternative(UnionFixtureAlternative): @@ -341,8 +341,15 @@ def parametrize_plus(argnames=None, # type: str :param args: additional {argnames: argvalues} definition :return: """ - return _parametrize_plus(argnames, argvalues, indirect=indirect, ids=ids, idgen=idgen, idstyle=idstyle, scope=scope, - hook=hook, debug=debug, **args) + _decorate, needs_inject = _parametrize_plus(argnames, argvalues, indirect=indirect, ids=ids, idgen=idgen, + idstyle=idstyle, scope=scope, hook=hook, debug=debug, **args) + if needs_inject: + @inject_host + def _apply_parametrize_plus(f, host_class_or_module): + return _decorate(f, host_class_or_module) + return _apply_parametrize_plus + else: + return _decorate class InvalidIdTemplateException(Exception): @@ -372,10 +379,13 @@ def _parametrize_plus(argnames=None, idgen=_IDGEN, # type: Union[str, Callable] scope=None, # type: str hook=None, # type: Callable[[Callable], Callable] - _frame_offset=2, debug=False, # type: bool **args): + """ + :return: a tuple (decorator, needs_inject) where needs_inject is True if decorator has signature (f, host) + and False if decorator has signature (f) + """ # idgen default if idgen is _IDGEN: # default: use the new id style only when some **args are provided @@ -421,7 +431,7 @@ def _make_ids(**args): _decorator = pytest.mark.parametrize(initial_argnames, marked_argvalues, indirect=indirect, ids=ids, scope=scope) if indirect: - return _decorator + return _decorator, False else: # wrap the decorator to check if the test function has the parameters as arguments def _apply(test_func): @@ -432,7 +442,7 @@ def _apply(test_func): "" % (p, test_func.__name__, s)) return _decorator(test_func) - return _apply + return _apply, False else: if indirect: @@ -440,10 +450,9 @@ def _apply(test_func): "the `argvalues`.") if debug: - print("Fixture references found. Creating fixtures...") + print("Fixture references found. Creating references and fixtures...") # there are fixture references: we will create a specific decorator replacing the params with a "union" fixture - caller_module = get_caller_module(frame_offset=_frame_offset) param_names_str = '_'.join(argnames).replace(' ', '') # First define a few functions that will help us create the various fixtures to use in the final "union" @@ -474,7 +483,7 @@ def _tmp_make_id(argvals): _tmp_make_id.i = -1 return _tmp_make_id - def _create_params_alt(test_func_name, union_name, from_i, to_i, hook): # noqa + def _create_params_alt(fh, test_func_name, union_name, from_i, to_i, hook): # noqa """ Routine that will be used to create a parameter fixture for argvalues between prev_i and i""" # check if this is about a single value or several values @@ -485,16 +494,14 @@ def _create_params_alt(test_func_name, union_name, from_i, to_i, hook): # noqa # Create a unique fixture name p_fix_name = "%s_%s_P%s" % (test_func_name, param_names_str, i) - p_fix_name = check_name_available(caller_module, p_fix_name, if_name_exists=CHANGE, - caller=parametrize_plus) + p_fix_name = check_name_available(fh, p_fix_name, if_name_exists=CHANGE, caller=parametrize_plus) if debug: - print("Creating fixture %r to handle parameter %s" % (p_fix_name, i)) + print(" - Creating new fixture %r to handle parameter %s" % (p_fix_name, i)) # Create the fixture that will return the unique parameter value ("auto-simplify" flag) # IMPORTANT that fixture is NOT parametrized so has no id nor marks: use argvalues not marked_argvalues - _create_param_fixture(caller_module, argname=p_fix_name, argvalues=argvalues[i:i+1], hook=hook, - auto_simplify=True) + _create_param_fixture(fh, argname=p_fix_name, argvalues=argvalues[i:i + 1], hook=hook, auto_simplify=True) # Create the alternative argvals = (argvalues[i],) if nb_params == 1 else argvalues[i] @@ -507,11 +514,10 @@ def _create_params_alt(test_func_name, union_name, from_i, to_i, hook): # noqa else: # Create a unique fixture name p_fix_name = "%s_%s_is_P%stoP%s" % (test_func_name, param_names_str, from_i, to_i - 1) - p_fix_name = check_name_available(caller_module, p_fix_name, if_name_exists=CHANGE, - caller=parametrize_plus) + p_fix_name = check_name_available(fh, p_fix_name, if_name_exists=CHANGE, caller=parametrize_plus) if debug: - print("Creating fixture %r to handle parameters %s to %s" % (p_fix_name, from_i, to_i - 1)) + print(" - Creating new fixture %r to handle parameters %s to %s" % (p_fix_name, from_i, to_i - 1)) # If an explicit list of ids was provided, slice it. Otherwise use the provided callable try: @@ -530,7 +536,7 @@ def _create_params_alt(test_func_name, union_name, from_i, to_i, hook): # noqa _argvals = tuple(ParameterSet((vals, ), id=id, marks=marks or ()) for vals, id, marks in zip(argvalues[from_i:to_i], p_ids[from_i:to_i], p_marks[from_i:to_i])) - _create_param_fixture(caller_module, argname=p_fix_name, argvalues=_argvals, ids=param_ids, hook=hook) + _create_param_fixture(fh, argname=p_fix_name, argvalues=_argvals, ids=param_ids, hook=hook) # todo put back debug=debug above @@ -546,7 +552,7 @@ def _create_fixture_ref_alt(union_name, i): # noqa f_fix_name = argvalues[i].fixture if debug: - print("Creating reference to fixture %r" % (f_fix_name,)) + print(" - Creating reference to existing fixture %r" % (f_fix_name,)) # Create the alternative f_fix_alt = FixtureParamAlternative(union_name=union_name, alternative_name=f_fix_name, @@ -557,7 +563,7 @@ def _create_fixture_ref_alt(union_name, i): # noqa return f_fix_name, f_fix_alt - def _create_fixture_ref_product(union_name, i, fixture_ref_positions, test_func_name, hook): # noqa + def _create_fixture_ref_product(fh, union_name, i, fixture_ref_positions, test_func_name, hook): # noqa # If an explicit list of ids was provided, slice it. Otherwise use the provided callable try: @@ -570,13 +576,13 @@ def _create_fixture_ref_product(union_name, i, fixture_ref_positions, test_func_ # Create a unique fixture name p_fix_name = "%s_%s_P%s" % (test_func_name, param_names_str, i) - p_fix_name = check_name_available(caller_module, p_fix_name, if_name_exists=CHANGE, caller=parametrize_plus) + p_fix_name = check_name_available(fh, p_fix_name, if_name_exists=CHANGE, caller=parametrize_plus) if debug: - print("Creating fixture %r to handle parameter %s that is a cross-product" % (p_fix_name, i)) + print(" - Creating new fixture %r to handle parameter %s that is a cross-product" % (p_fix_name, i)) # Create the fixture - _make_fixture_product(caller_module, name=p_fix_name, hook=hook, caller=parametrize_plus, ids=param_ids, + _make_fixture_product(fh, name=p_fix_name, hook=hook, caller=parametrize_plus, ids=param_ids, fixtures_or_values=param_values, fixture_positions=fixture_ref_positions) # Create the corresponding alternative @@ -589,7 +595,7 @@ def _create_fixture_ref_product(union_name, i, fixture_ref_positions, test_func_ return p_fix_name, p_fix_alt # Then create the decorator per se - def parametrize_plus_decorate(test_func): + def parametrize_plus_decorate(test_func, fixtures_dest): """ A decorator that wraps the test function so that instead of receiving the parameter names, it receives the new fixture. All other decorations are unchanged. @@ -619,7 +625,7 @@ def parametrize_plus_decorate(test_func): # style_template = "%s_param__%s" main_fixture_style_template = "%s_%s" fixture_union_name = main_fixture_style_template % (test_func_name, param_names_str) - fixture_union_name = check_name_available(caller_module, fixture_union_name, if_name_exists=CHANGE, + fixture_union_name = check_name_available(fixtures_dest, fixture_union_name, if_name_exists=CHANGE, caller=parametrize_plus) # Retrieve (if ref) or create (for normal argvalues) the fixtures that we will union @@ -633,7 +639,7 @@ def parametrize_plus_decorate(test_func): # one for each consecutive group as shown below. This should not lead to different results but perf # might differ. Maybe add a parameter in the signature so that users can test it ? # this would make the ids more readable by removing the "P2toP3"-like ids - p_fix_name, p_fix_alt = _create_params_alt(test_func_name=test_func_name, hook=hook, + p_fix_name, p_fix_alt = _create_params_alt(fixtures_dest, test_func_name=test_func_name, hook=hook, union_name=fixture_union_name, from_i=prev_i + 1, to_i=i) fixture_alternatives.append((p_fix_name, p_fix_alt)) if explicit_ids_to_use is not None: @@ -653,7 +659,8 @@ def parametrize_plus_decorate(test_func): else: # argvalues[i] is a tuple, some of them being fixture_ref. create a fixture refering to all of them - prod_fix_name, prod_fix_alt = _create_fixture_ref_product(union_name=fixture_union_name, i=i, + prod_fix_name, prod_fix_alt = _create_fixture_ref_product(fixtures_dest, + union_name=fixture_union_name, i=i, fixture_ref_positions=j_list, test_func_name=test_func_name, hook=hook) fixture_alternatives.append((prod_fix_name, prod_fix_alt)) @@ -665,8 +672,8 @@ def parametrize_plus_decorate(test_func): # C/ handle last consecutive group of normal parameters, if any i = len(argvalues) # noqa if i > prev_i + 1: - p_fix_name, p_fix_alt = _create_params_alt(test_func_name=test_func_name, union_name=fixture_union_name, - from_i=prev_i + 1, to_i=i, hook=hook) + p_fix_name, p_fix_alt = _create_params_alt(fixtures_dest, test_func_name=test_func_name, hook=hook, + union_name=fixture_union_name, from_i=prev_i + 1, to_i=i) fixture_alternatives.append((p_fix_name, p_fix_alt)) if explicit_ids_to_use is not None: if isinstance(p_fix_alt, SingleParamAlternative): @@ -692,10 +699,11 @@ def parametrize_plus_decorate(test_func): # Finally create a "main" fixture with a unique name for this test function if debug: - print("Creating final union fixture %r with alternatives %r" % (fixture_union_name, fix_alternatives)) + print("Creating final union fixture %r with alternatives %r" + % (fixture_union_name, UnionFixtureAlternative.to_list_of_fixture_names(fix_alternatives))) # note: the function automatically registers it in the module - _make_fixture_union(caller_module, name=fixture_union_name, hook=hook, caller=parametrize_plus, + _make_fixture_union(fixtures_dest, name=fixture_union_name, hook=hook, caller=parametrize_plus, fix_alternatives=fix_alternatives, unique_fix_alt_names=fix_alt_names, ids=explicit_ids_to_use or ids or ParamIdMakers.get(idstyle)) @@ -765,7 +773,7 @@ def wrapped_test_func(*args, **kwargs): # noqa # return the new test function return wrapped_test_func - return parametrize_plus_decorate + return parametrize_plus_decorate, True def _get_argnames_argvalues(argnames=None, argvalues=None, **args):