diff --git a/ci_tools/requirements-conda.txt b/ci_tools/requirements-conda.txt index 5eeca0ba..ad9903b6 100644 --- a/ci_tools/requirements-conda.txt +++ b/ci_tools/requirements-conda.txt @@ -5,6 +5,7 @@ pandoc pypandoc # --- to install +decorator # --- to run the tests # (pip) pytest$PYTEST_VERSION diff --git a/pytest_cases/__init__.py b/pytest_cases/__init__.py index c6b51af5..2883a929 100644 --- a/pytest_cases/__init__.py +++ b/pytest_cases/__init__.py @@ -5,13 +5,14 @@ except ImportError: pass -from pytest_cases.main import cases_data, CaseDataGetter, unfold_expected_err, extract_cases_from_module, THIS_MODULE +from pytest_cases.main import cases_data, CaseDataGetter, cases_fixture, \ + unfold_expected_err, extract_cases_from_module, THIS_MODULE __all__ = [ # the 2 submodules 'main', 'case_funcs', # all symbols imported above - 'cases_data', 'CaseData', 'CaseDataGetter', 'unfold_expected_err', 'extract_cases_from_module', + 'cases_data', 'CaseData', 'CaseDataGetter', 'cases_fixture', 'unfold_expected_err', 'extract_cases_from_module', 'case_name', 'Given', 'ExpectedNormal', 'ExpectedError', 'test_target', 'case_tags', 'THIS_MODULE', 'cases_generator', 'MultipleStepsCaseData' ] diff --git a/pytest_cases/decorator_hack.py b/pytest_cases/decorator_hack.py new file mode 100644 index 00000000..ef57cb53 --- /dev/null +++ b/pytest_cases/decorator_hack.py @@ -0,0 +1,277 @@ +import sys + +from itertools import chain + +from decorator import FunctionMaker +from inspect import isgeneratorfunction + +try: # python 3.3+ + from inspect import signature +except ImportError: + from funcsigs import signature + +try: + from decorator import iscoroutinefunction +except ImportError: + try: + from inspect import iscoroutinefunction + except ImportError: + # let's assume there are no coroutine functions in old Python + def iscoroutinefunction(f): + return False + + +class MyFunctionMaker(FunctionMaker): + """ + Overrides FunctionMaker so that additional arguments can be inserted in the resulting signature. + """ + + def refresh_signature(self): + """Update self.signature and self.shortsignature based on self.args, + self.varargs, self.varkw""" + allargs = list(self.args) + allshortargs = list(self.args) + if self.varargs: + allargs.append('*' + self.varargs) + allshortargs.append('*' + self.varargs) + elif self.kwonlyargs: + allargs.append('*') # single star syntax + for a in self.kwonlyargs: + allargs.append('%s=None' % a) + allshortargs.append('%s=%s' % (a, a)) + if self.varkw: + allargs.append('**' + self.varkw) + allshortargs.append('**' + self.varkw) + self.signature = ', '.join(allargs) + self.shortsignature = ', '.join(allshortargs) + + @classmethod + def create(cls, obj, body, evaldict, defaults=None, + doc=None, module=None, addsource=True, add_args=(), del_args=(), **attrs): + """ + Create a function from the strings name, signature and body. + evaldict is the evaluation dictionary. If addsource is true an + attribute __source__ is added to the result. The attributes attrs + are added, if any. + + If add_args is not empty, these arguments will be prepended to the + positional arguments. + + If del_args is not empty, these arguments will be removed from signature + """ + if isinstance(obj, str): # "name(signature)" + name, rest = obj.strip().split('(', 1) + signature = rest[:-1] # strip a right parens + func = None + else: # a function + name = None + signature = None + func = obj + self = cls(func, name, signature, defaults, doc, module) + ibody = '\n'.join(' ' + line for line in body.splitlines()) + caller = evaldict.get('_call_') # when called from `decorate` + if caller and iscoroutinefunction(caller): + body = ('async def %(name)s(%(signature)s):\n' + ibody).replace( + 'return', 'return await') + else: + body = 'def %(name)s(%(signature)s):\n' + ibody + + # Handle possible signature changes + sig_modded = False + if len(add_args) > 0: + # prepend them as positional args - hence the reversed() + for arg in reversed(add_args): + if arg not in self.args: + self.args = [arg] + self.args + sig_modded = True + else: + # the argument already exists in the wrapped + # function, nothing to do. + pass + + if len(del_args) > 0: + # remove the args + for to_remove in del_args: + for where_field in ('args', 'varargs', 'varkw', 'defaults', 'kwonlyargs', 'kwonlydefaults'): + a = getattr(self, where_field, None) + if a is not None and to_remove in a: + try: + # list + a.remove(to_remove) + except AttributeError: + # dict-like + del a[to_remove] + finally: + sig_modded = True + + if sig_modded: + self.refresh_signature() + + # make the function + func = self.make(body, evaldict, addsource, **attrs) + + if sig_modded: + # delete this annotation otherwise inspect.signature + # will wrongly return the signature of func.__wrapped__ + # instead of the signature of func + func.__wrapped_with_addargs__ = func.__wrapped__ + del func.__wrapped__ + + return func + + +def _extract_additional_args(f_sig, add_args_names, args, kwargs, put_all_in_kwargs=False): + """ + Processes the arguments received by our caller so that at the end, args + and kwargs only contain what is needed by f (according to f_sig). All + additional arguments are returned separately, in order described by + `add_args_names`. If some names in `add_args_names` are present in `f_sig`, + then the arguments will appear both in the additional arguments and in + *args, **kwargs. + + In the end, only *args can possibly be modified by the procedure (by removing + from it all additional arguments that were not in f_sig and were prepended). + + So the result is a tuple (add_args, args) + + :return: a tuple (add_args, args) where `add_args` are the values of + arguments named in `add_args_names` in the same order ; and `args` is + the positional arguments to send to the wrapped function together with + kwargs (args now only contains the positional args that are required by + f, without the extra ones) + """ + # -- first extract (and remove) the 'truly' additional ones (the ones not in the signature) + add_args = [None] * len(add_args_names) + for i, arg_name in enumerate(add_args_names): + if arg_name not in f_sig.parameters: + # remove this argument from the args and put it in the right place + add_args[i] = args[0] + args = args[1:] + + # -- then copy the ones that already exist in the signature. Thanks,inspect pkg! + bound = f_sig.bind(*args, **kwargs) + for i, arg_name in enumerate(add_args_names): + if arg_name in f_sig.parameters: + add_args[i] = bound.arguments[arg_name] + + # -- finally move args to kwargs of needed + if put_all_in_kwargs: + args = tuple() + kwargs = {arg_name: bound.arguments[arg_name] for arg_name in f_sig.parameters} + + return add_args, args, kwargs + + +def _wrap_caller_for_additional_args(func, caller, additional_args, removed_args): + """ + This internal function wraps the caller so as to handle all cases + (if some additional args are already present in the signature or not) + so as to ensure a consistent caller signature. + + Note: as of today if removed_args is not empty, positional args can not be correctly handled so all arguments + are passed as kwargs to the wrapper + + :return: a new caller wrapping the caller, to be used in `decorate` + """ + f_sig = signature(func) + + # We will create a caller above the original caller in order to check + # if additional_args are already present in the signature or not, and + # act accordingly + original_caller = caller + + # If we have to remove the parameters, the behaviour and signatures will be a bit different + # First modify the signature so that we remove the parameters that have to be. + if len(removed_args) > 0: + # new_params = OrderedDict(((k, v) for k, v in f_sig.parameters.items() if k not in removed_args)) + new_params = (v for k, v in f_sig.parameters.items() if k not in removed_args) + f_sig = f_sig.replace(parameters=new_params) + + # -- then create the appropriate function signature according to + # wrapped function signature assume that original_caller has all + # additional args as first positional arguments, in order + if not isgeneratorfunction(original_caller): + def caller(f, *args, **kwargs): + # Retrieve the values for additional args. + add_args, args, kwargs = _extract_additional_args(f_sig, additional_args, + args, kwargs, + put_all_in_kwargs=(len(removed_args) > 0)) + + # Call the original caller + # IMPORTANT : args and kwargs are passed without the double-star here! + return original_caller(f, *add_args, args=args, kwargs=kwargs) + else: + def caller(f, *args, **kwargs): + # Retrieve the value for additional args. + add_args, args, kwargs = _extract_additional_args(f_sig, additional_args, + args, kwargs, + put_all_in_kwargs=(len(removed_args) > 0)) + + # Call the original caller + # IMPORTANT : args and kwargs are passed without the double-star here! + for res in original_caller(f, *add_args, args=args, kwargs=kwargs): + yield res + + return caller + + +def my_decorate(func, caller, extras=(), additional_args=(), removed_args=(), pytest_place_as=True): + """ + decorate(func, caller) decorates a function using a caller. + If the caller is a generator function, the resulting function + will be a generator function. + + You can provide additional arguments with `additional_args`. In that case + the caller's signature should be + + `caller(f, , *args, **kwargs)`. + + `*args, **kwargs` will always contain the arguments required by the inner + function `f`. If `additional_args` contains argument names that are already + present in `func`, they will be present both in + AND in `*args, **kwargs` so that it remains easy for the `caller` both to + get the additional arguments' values directly, and to call `f` with the + right arguments. + + Note: as of today if removed_args is not empty, positional args can not be correctly handled so all arguments + are passed as kwargs to the wrapper + + """ + if len(additional_args) > 0: + # wrap the caller so as to handle all cases + # (if some additional args are already present in the signature or not) + # so as to ensure a consistent caller signature + caller = _wrap_caller_for_additional_args(func, caller, additional_args, removed_args) + + evaldict = dict(_call_=caller, _func_=func) + es = '' + for i, extra in enumerate(extras): + ex = '_e%d_' % i + evaldict[ex] = extra + es += ex + ', ' + + if '3.5' <= sys.version < '3.6': + # with Python 3.5 isgeneratorfunction returns True for all coroutines + # however we know that it is NOT possible to have a generator + # coroutine in python 3.5: PEP525 was not there yet + generatorcaller = isgeneratorfunction( + caller) and not iscoroutinefunction(caller) + else: + generatorcaller = isgeneratorfunction(caller) + if generatorcaller: + fun = MyFunctionMaker.create( + func, "for res in _call_(_func_, %s%%(shortsignature)s):\n" + " yield res" % es, evaldict, + add_args=additional_args, del_args=removed_args, __wrapped__=func) + else: + fun = MyFunctionMaker.create( + func, "return _call_(_func_, %s%%(shortsignature)s)" % es, + evaldict, add_args=additional_args, del_args=removed_args, __wrapped__=func) + if hasattr(func, '__qualname__'): + fun.__qualname__ = func.__qualname__ + + # With this hack our decorator will be ordered correctly by pytest https://github.com/pytest-dev/pytest/issues/4429 + if pytest_place_as: + fun.place_as = func + + return fun diff --git a/pytest_cases/main.py b/pytest_cases/main.py index 21f846a4..f9bdcb02 100644 --- a/pytest_cases/main.py +++ b/pytest_cases/main.py @@ -2,14 +2,17 @@ import sys from abc import abstractmethod, ABCMeta -from inspect import getmembers -from types import ModuleType +from inspect import getmembers, isgeneratorfunction -try: # python 3+ +from pytest_cases.decorator_hack import my_decorate + +try: # type hints, python 3+ from typing import Callable, Union, Optional, Any, Tuple, List, Dict, Iterable from pytest_cases.case_funcs import CaseData, ExpectedError + from types import ModuleType + # Type hint for the simple functions CaseFunc = Callable[[], CaseData] @@ -122,6 +125,58 @@ def get(self, *args, **kwargs): """Marker that can be used instead of a module name to indicate that the module is the current one""" +def cases_fixture(cases=None, # type: Union[Callable[[Any], Any], Iterable[Callable[[Any], Any]]] + module=None, # type: Union[ModuleType, Iterable[ModuleType]] + case_data_argname='case_data', # type: str + has_tag=None, # type: Any + filter=None, # type: Callable[[List[Any]], bool] + **kwargs + ): + """ + Decorates a function so that it becomes a parametrized fixture. + + :param cases: + :param module: + :param case_data_argname: + :param has_tag: + :param filter: + :return: + """ + def fixture_decorator(fixture_func): + """ + The generated fixture function decorator. + + :param fixture_func: + :return: + """ + # First list all cases according to user preferences + _cases = get_all_cases(cases, module, fixture_func, has_tag, filter) + + # old: use id getter function : cases_ids = str + # new: hardcode the case ids, safer (?) in case this is mixed with another fixture + cases_ids = [str(c) for c in _cases] + + # create a fixture function wrapper + if not isgeneratorfunction(fixture_func): + def wrapper(f, request, args, kwargs): + kwargs[case_data_argname] = request.param + return f(*args, **kwargs) + else: + def wrapper(f, request, args, kwargs): + kwargs[case_data_argname] = request.param + for res in f(*args, **kwargs): + yield res + + fixture_func_wrapper = my_decorate(fixture_func, wrapper, additional_args=['request'], + removed_args=[case_data_argname]) + + # Finally create the pytest decorator and apply it + parametrizer = pytest.fixture(params=_cases, ids=cases_ids, **kwargs) + return parametrizer(fixture_func_wrapper) + + return fixture_decorator + + def cases_data(cases=None, # type: Union[Callable[[Any], Any], Iterable[Callable[[Any], Any]]] module=None, # type: Union[ModuleType, Iterable[ModuleType]] case_data_argname='case_data', # type: str @@ -189,27 +244,8 @@ def datasets_decorator(test_func): :param test_func: :return: """ - if module is not None and cases is not None: - raise ValueError("Only one of module and cases should be provided") - elif module is None: - # Hardcoded sequence of cases, or single case - if callable(cases): - # single element - _cases = [case_getter for case_getter in _get_case_getter_s(cases)] - else: - # already a sequence - _cases = [case_getter for c in cases for case_getter in _get_case_getter_s(c)] - else: - # Gather all cases from the reference module(s) - try: - _cases = [] - for m in module: - m = sys.modules[test_func.__module__] if m is THIS_MODULE else m - _cases += extract_cases_from_module(m, has_tag=has_tag, filter=filter) - except TypeError: - # 'module' object is not iterable: a single module was provided - m = sys.modules[test_func.__module__] if module is THIS_MODULE else module - _cases = extract_cases_from_module(m, has_tag=has_tag, filter=filter) + # First list all cases according to user preferences + _cases = get_all_cases(cases, module, test_func, has_tag, filter) # old: use id getter function : cases_ids = str # new: hardcode the case ids, safer (?) in case this is mixed with another fixture @@ -223,6 +259,45 @@ def datasets_decorator(test_func): return datasets_decorator +def get_all_cases(cases, module, test_func, has_tag, filter): + """ + Internal method to get all desired cases from the user inputs. + + :param cases: a single case or a hardcoded list of cases to use. Only one of `cases` and `module` should be set. + :param module: a module or a hardcoded list of modules to use. You may use `THIS_MODULE` to indicate that the + module is the current one. Only one of `cases` and `module` should be set. + :param test_func: the test function. It is used when module contains `THIS_MODULE`, to find the module. + :param has_tag: an optional tag used to filter the cases. Only cases with the given tag will be selected. Only + cases with the given tag will be selected. + :param filter: an optional filtering function taking as an input a list of tags associated with a case, and + returning a boolean indicating if the case should be selected. It will be used to filter the cases in the + `module`. It both `has_tag` and `filter` are set, both will be applied in sequence. + :return: + """ + if module is not None and cases is not None: + raise ValueError("Only one of module and cases should be provided") + elif module is None: + # Hardcoded sequence of cases, or single case + if callable(cases): + # single element + _cases = [case_getter for case_getter in _get_case_getter_s(cases)] + else: + # already a sequence + _cases = [case_getter for c in cases for case_getter in _get_case_getter_s(c)] + else: + # Gather all cases from the reference module(s) + try: + _cases = [] + for m in module: + m = sys.modules[test_func.__module__] if m is THIS_MODULE else m + _cases += extract_cases_from_module(m, has_tag=has_tag, filter=filter) + except TypeError: + # 'module' object is not iterable: a single module was provided + m = sys.modules[test_func.__module__] if module is THIS_MODULE else module + _cases = extract_cases_from_module(m, has_tag=has_tag, filter=filter) + + return _cases + def _get_code(f): """ Returns the source code associated to function f. It is robust to wrappers such as @lru_cache diff --git a/pytest_cases/tests/simple/test_fixtures.py b/pytest_cases/tests/simple/test_fixtures.py new file mode 100644 index 00000000..dab9f649 --- /dev/null +++ b/pytest_cases/tests/simple/test_fixtures.py @@ -0,0 +1,42 @@ +import pytest +from pytest_cases.tests.simple import test_main_cases + +from pytest_cases import unfold_expected_err, cases_fixture + +from pytest_cases.tests.example_code import super_function_i_want_to_test + + +@cases_fixture(module=test_main_cases) +def my_case_fixture(case_data, request): + """Getting data will now be executed BEFORE the test (outside of the test duration)""" + return case_data.get() + + +def test_with_cases_decorated(my_case_fixture): + """ Example unit test that is automatically parametrized with @cases_data """ + + # 1- Grab the test case data + i, expected_o, expected_e = my_case_fixture + + # 2- Use it + if expected_e is None: + # **** Nominal test **** + outs = super_function_i_want_to_test(**i) + assert outs == expected_o + + else: + # **** Error test **** + # First see what we need to assert + err_type, err_inst, err_checker = unfold_expected_err(expected_e) + + # Run with exception capture and type check + with pytest.raises(err_type) as err_info: + super_function_i_want_to_test(**i) + + # Optional - Additional Exception instance check + if err_inst is not None: + assert err_info.value == err_inst + + # Optional - Additional exception instance check + if err_checker is not None: + err_checker(err_info.value) diff --git a/setup.py b/setup.py index 321d816d..ef24af6d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ here = path.abspath(path.dirname(__file__)) # *************** Dependencies ********* -INSTALL_REQUIRES = [] +INSTALL_REQUIRES = ['decorator'] DEPENDENCY_LINKS = [] SETUP_REQUIRES = ['pytest-runner', 'setuptools_scm', 'pypandoc', 'pandoc'] TESTS_REQUIRE = ['pytest', 'pytest-logging', 'pytest-cov', 'pytest-steps']