diff --git a/ci_tools/requirements-pip.txt b/ci_tools/requirements-pip.txt index 4df1113b..796036fe 100644 --- a/ci_tools/requirements-pip.txt +++ b/ci_tools/requirements-pip.txt @@ -6,7 +6,6 @@ setuptools_scm # -- to install makefun>=1.7.0 decopatch -wrapt # --- to generate the reports (see scripts in ci_tools, called by .travis) pytest-html$PYTEST_HTML_VERSION diff --git a/docs/changelog.md b/docs/changelog.md index d64943eb..aaf05a9d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # Changelog +### 1.15.0 - better `parametrize_plus` and smaller dependencies + + - Better support for `pytest.param` in `parametrize_plus` and also in `fixture_union` and `fixture_param[s]`. Improved corresponding ids. Fixed [#79](https://github.com/smarie/python-pytest-cases/issues/79) and [#86](https://github.com/smarie/python-pytest-cases/issues/86) + + - Removed `six`, `wrapt` and `enum34` dependencies + ### 1.14.0 - bugfixes and hook feature - Fixed `ids` precedence order when using `pytest.mark.parametrize` in a `fixture_plus`. Fixed [#87](https://github.com/smarie/python-pytest-cases/issues/87) diff --git a/pytest_cases/common.py b/pytest_cases/common.py index 721fbfb4..51598805 100644 --- a/pytest_cases/common.py +++ b/pytest_cases/common.py @@ -4,14 +4,14 @@ from funcsigs import signature try: - from typing import Union, Callable, Any + from typing import Union, Callable, Any, Optional except ImportError: pass from distutils.version import LooseVersion from warnings import warn -from six import string_types +from .mini_six import string_types import pytest @@ -274,38 +274,90 @@ def combine_ids(paramid_tuples): return ['-'.join(pid for pid in testid) for testid in paramid_tuples] -def get_test_ids_from_param_values(param_names, - param_values, - ): +def make_test_ids(global_ids, id_marks, argnames=None, argvalues=None, precomputed_ids=None): """ - Replicates pytest behaviour to generate the ids when there are several parameters in a single `parametrize` + Creates the proper id for each test based on (higher precedence first) + + - any specific id mark from a `pytest.param` (`id_marks`) + - the global `ids` argument of pytest parametrize (`global_ids`) + - the name and value of parameters (`argnames`, `argvalues`) or the precomputed ids(`precomputed_ids`) + + See also _pytest.python._idvalset method + + :param global_ids: + :param id_marks: + :param argnames: + :param argvalues: + :param precomputed_ids: + :return: + """ + if global_ids is not None: + # overridden at global pytest.mark.parametrize level - this takes precedence. + try: # an explicit list of ids ? + p_ids = list(global_ids) + except TypeError: # a callable to apply on the values + p_ids = list(global_ids(v) for v in argvalues) + else: + # default: values-based + if precomputed_ids is not None: + if argnames is not None or argvalues is not None: + raise ValueError("Only one of `precomputed_ids` or argnames/argvalues should be provided.") + p_ids = precomputed_ids + else: + p_ids = make_test_ids_from_param_values(argnames, argvalues) + + # Finally, local pytest.param takes precedence over everything else + for i, _id in enumerate(id_marks): + if _id is not None: + p_ids[i] = _id + return p_ids + + +def make_test_ids_from_param_values(param_names, + param_values, + ): + """ + Replicates pytest behaviour to generate the ids when there are several parameters in a single `parametrize. + Note that param_values should not contain marks. :param param_names: :param param_values: :return: a list of param ids """ + if isinstance(param_names, string_types): + raise TypeError("param_names must be an iterable. Found %r" % param_names) + nb_params = len(param_names) if nb_params == 0: raise ValueError("empty list provided") elif nb_params == 1: - paramids = list(str(v) for v in param_values) + paramids = [] + for _idx, v in enumerate(param_values): + _id = mini_idvalset(param_names, (v,), _idx) + paramids.append(_id) else: paramids = [] - for vv in param_values: + for _idx, vv in enumerate(param_values): if len(vv) != nb_params: raise ValueError("Inconsistent lenghts for parameter names and values: '%s' and '%s'" "" % (param_names, vv)) - paramids.append('-'.join([str(v) for v in vv])) + _id = mini_idvalset(param_names, vv, _idx) + paramids.append(_id) return paramids # ---- ParameterSet api --- -def analyze_parameter_set(pmark=None, argnames=None, argvalues=None, ids=None): +def analyze_parameter_set(pmark=None, argnames=None, argvalues=None, ids=None, check_nb=True): """ analyzes a parameter set passed either as a pmark or as distinct (argnames, argvalues, ids) to extract/construct the various ids, marks, and values + See also pytest.Metafunc.parametrize method, that calls in particular + pytest.ParameterSet._for_parametrize and _pytest.python._idvalset + + :param check_nb: a bool indicating if we should raise an error if len(argnames) > 1 and any argvalue has + a different length than len(argnames) :return: ids, marks, values """ if pmark is not None: @@ -316,38 +368,31 @@ def analyze_parameter_set(pmark=None, argnames=None, argvalues=None, ids=None): ids = pmark.param_ids # extract all parameters that have a specific configuration (pytest.param()) - custom_pids, p_marks, p_values = extract_parameterset_info(argnames, argvalues) - - # Create the proper id for each test - if ids is not None: - # overridden at global pytest.mark.parametrize level - this takes precedence. - try: # an explicit list of ids ? - p_ids = list(ids) - except TypeError: # a callable to apply on the values - p_ids = list(ids(v) for v in p_values) - else: - # default: values-based - p_ids = get_test_ids_from_param_values(argnames, p_values) + custom_pids, p_marks, p_values = extract_parameterset_info(argnames, argvalues, check_nb=check_nb) - # Finally, local pytest.param takes precedence over everything else - for i, _id in enumerate(custom_pids): - if _id is not None: - p_ids[i] = _id + # get the ids by merging/creating the various possibilities + p_ids = make_test_ids(argnames=argnames, argvalues=p_values, global_ids=ids, id_marks=custom_pids) return p_ids, p_marks, p_values -def extract_parameterset_info(argnames, argvalues): +def extract_parameterset_info(argnames, argvalues, check_nb=True): """ :param argnames: the names in this parameterset :param argvalues: the values in this parameterset + :param check_nb: a bool indicating if we should raise an error if len(argnames) > 1 and any argvalue has + a different length than len(argnames) :return: """ pids = [] pmarks = [] pvalues = [] + if isinstance(argnames, string_types): + raise TypeError("argnames must be an iterable. Found %r" % argnames) + nbnames = len(argnames) for v in argvalues: + # is this a pytest.param() ? if is_marked_parameter_value(v): # --id id = get_marked_parameter_id(v) @@ -356,19 +401,21 @@ def extract_parameterset_info(argnames, argvalues): marks = get_marked_parameter_marks(v) pmarks.append(marks) # note: there might be several # --value(a tuple if this is a tuple parameter) - vals = get_marked_parameter_values(v) - if len(vals) != len(argnames): - raise ValueError("Internal error - unsupported pytest parametrization+mark combination. Please " - "report this issue") - if len(vals) == 1: - pvalues.append(vals[0]) + v = get_marked_parameter_values(v) + if nbnames == 1: + pvalues.append(v[0]) else: - pvalues.append(vals) + pvalues.append(v) else: + # normal argvalue pids.append(None) pmarks.append(None) pvalues.append(v) + if check_nb and nbnames > 1 and (len(v) != nbnames): + raise ValueError("Inconsistent number of values in pytest parametrize: %s items found while the " + "number of parameters is %s: %s." % (len(v), nbnames, v)) + return pids, pmarks, pvalues @@ -490,3 +537,43 @@ def get_pytest_scopenum(scope_str): def get_pytest_function_scopenum(): return pt_scopes.index("function") + + +from _pytest.python import _idval + + +if LooseVersion(pytest.__version__) >= LooseVersion('3.0.0'): + _idval_kwargs = dict(idfn=None, + item=None, # item is only used by idfn + config=None # if a config hook was available it would be used before this is called) + ) +else: + _idval_kwargs = dict(idfn=None, + # item=None, # item is only used by idfn + # config=None # if a config hook was available it would be used before this is called) + ) + + +def mini_idval( + val, # type: object + argname, # type: str + idx, # type: int + ): + """ + A simplified version of idval where idfn, item and config do not need to be passed. + + :param val: + :param argname: + :param idx: + :return: + """ + return _idval(val=val, argname=argname, idx=idx, **_idval_kwargs) + + +def mini_idvalset(argnames, argvalues, idx): + """ mimic _pytest.python._idvalset """ + this_id = [ + _idval(val, argname, idx=idx,**_idval_kwargs) + for val, argname in zip(argvalues, argnames) + ] + return "-".join(this_id) diff --git a/pytest_cases/main_fixtures.py b/pytest_cases/main_fixtures.py index 31c00511..e6ecd0f7 100644 --- a/pytest_cases/main_fixtures.py +++ b/pytest_cases/main_fixtures.py @@ -2,7 +2,6 @@ from __future__ import division from distutils.version import LooseVersion -from enum import Enum from inspect import isgeneratorfunction, getmodule, currentframe from itertools import product from warnings import warn @@ -10,7 +9,6 @@ from decopatch import function_decorator, DECORATED from makefun import with_signature, add_signature_parameters, remove_signature_parameters, wraps -from six import string_types import pytest try: # python 3.3+ @@ -39,13 +37,17 @@ except ImportError: pass -from pytest_cases.common import yield_fixture, get_pytest_parametrize_marks, get_test_ids_from_param_values, \ - make_marked_parameter_value, get_fixture_name, get_param_argnames_as_list, analyze_parameter_set, combine_ids, \ - get_fixture_scope, remove_duplicates +from pytest_cases.common import yield_fixture, get_pytest_parametrize_marks, make_marked_parameter_value, \ + get_fixture_name, get_param_argnames_as_list, analyze_parameter_set, combine_ids, get_fixture_scope, \ + remove_duplicates, extract_parameterset_info, is_marked_parameter_value, get_marked_parameter_values, mini_idvalset from pytest_cases.main_params import cases_data +from pytest_cases.mini_six import string_types -def unpack_fixture(argnames, fixture, hook=None): +def unpack_fixture(argnames, + fixture, + hook=None # type: Callable[[Callable], Callable] + ): """ Creates several fixtures with names `argnames` from the source `fixture`. Created fixtures will correspond to elements unpacked from `fixture` in order. For example if `fixture` is a tuple of length 2, `argnames="a,b"` will @@ -135,7 +137,13 @@ def _param_fixture(**kwargs): return created_fixtures -def param_fixture(argname, argvalues, autouse=False, ids=None, scope="function", hook=None, **kwargs): +def param_fixture(argname, + argvalues, + autouse=False, # type: bool + ids=None, # type: Union[Callable, List[str]] + scope="function", # type: str + hook=None, # type: Callable[[Callable], Callable] + **kwargs): """ Identical to `param_fixtures` but for a single parameter name, so that you can assign its output to a single variable. @@ -175,21 +183,41 @@ def test_uses_param(my_parameter, fixture_uses_param): caller_module = get_caller_module() - return _param_fixture(caller_module, argname, argvalues, autouse=autouse, ids=ids, scope=scope, - hook=hook, **kwargs) + return _create_param_fixture(caller_module, argname, argvalues, autouse=autouse, ids=ids, scope=scope, + hook=hook, **kwargs) -def _param_fixture(caller_module, argname, argvalues, autouse=False, ids=None, scope="function", hook=None, - **kwargs): +def _create_param_fixture(caller_module, + argname, + argvalues, + autouse=False, # type: bool + ids=None, # type: Union[Callable, List[str]] + scope="function", # type: str + hook=None, # type: Callable[[Callable], Callable] + auto_simplify=False, + **kwargs): """ Internal method shared with param_fixture and param_fixtures """ - # create the fixture - set its name so that the optional hook can read it easily - @with_signature("%s(request)" % argname) - def __param_fixture(request): - return request.param + if auto_simplify and len(argvalues) == 1: + # Simplification: do not parametrize the fixture, it will directly return the single value + argvalue_to_return = argvalues[0] + if is_marked_parameter_value(argvalue_to_return): + argvalue_to_return = get_marked_parameter_values(argvalue_to_return) + + # create the fixture - set its name so that the optional hook can read it easily + @with_signature("%s()" % argname) + def __param_fixture(): + return argvalue_to_return + + fix = fixture_plus(name=argname, scope=scope, autouse=autouse, ids=ids, hook=hook, **kwargs)(__param_fixture) + else: + # create the fixture - set its name so that the optional hook can read it easily + @with_signature("%s(request)" % argname) + def __param_fixture(request): + return request.param - fix = fixture_plus(name=argname, scope=scope, autouse=autouse, params=argvalues, ids=ids, - hook=hook, **kwargs)(__param_fixture) + fix = fixture_plus(name=argname, scope=scope, autouse=autouse, params=argvalues, ids=ids, + 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) @@ -262,7 +290,13 @@ def check_name_available(module, return name -def param_fixtures(argnames, argvalues, autouse=False, ids=None, scope="function", hook=None, **kwargs): +def param_fixtures(argnames, + argvalues, + autouse=False, # type: bool + ids=None, # type: Union[Callable, List[str]] + scope="function", # type: str + hook=None, # type: Callable[[Callable], Callable] + **kwargs): """ Creates one or several "parameters" fixtures - depending on the number or coma-separated names in `argnames`. The created fixtures are automatically registered into the callers' module, but you may wish to assign them to @@ -305,8 +339,8 @@ def test_uses_param2(arg1, arg2, fixture_uses_param2): caller_module = get_caller_module() if len(argnames_lst) < 2: - return _param_fixture(caller_module, argnames, argvalues, autouse=autouse, ids=ids, scope=scope, - hook=hook, **kwargs) + return _create_param_fixture(caller_module, argnames, argvalues, autouse=autouse, ids=ids, scope=scope, + hook=hook, **kwargs) # create the root fixture that will contain all parameter values # note: we sort the list so that the first in alphabetical order appears first. Indeed pytest uses this order. @@ -461,11 +495,11 @@ def _fixture_plus(f): @function_decorator -def fixture_plus(scope="function", - autouse=False, - name=None, - unpack_into=None, - hook=None, +def fixture_plus(scope="function", # type: str + autouse=False, # type: bool + name=None, # type: str + unpack_into=None, # type: Iterable[str] + hook=None, # type: Callable[[Callable], Callable] fixture_func=DECORATED, **kwargs): """ decorator to mark a fixture factory function. @@ -509,7 +543,7 @@ def _decorate_fixture_plus(fixture_func, autouse=False, name=None, unpack_into=None, - hook=None, + hook=None, # type: Callable[[Callable], Callable] _caller_module_offset_when_unpack=3, **kwargs): """ decorator to mark a fixture factory function. @@ -590,7 +624,7 @@ def _decorate_fixture_plus(fixture_func, params_names_or_name_combinations.append(pmark.param_names) # analyse contents, extract marks and custom ids, apply custom ids - _paramids, _pmarks, _pvalues = analyze_parameter_set(pmark) + _paramids, _pmarks, _pvalues = analyze_parameter_set(pmark=pmark, check_nb=True) # Finally store the ids, marks, and values for this parameterset params_ids.append(_paramids) @@ -774,56 +808,69 @@ def __repr__(self): """Object representing a fixture value when the fixture is not used""" +class UnionIdMakers(object): + """ + The enum defining all possible id styles for union fixture parameters ("alternatives") + """ + @classmethod + def nostyle(cls, param): + return param.alternative_name + + @classmethod + def explicit(cls, param): + return "%s_is_%s" % (param.union_name, param.alternative_name) + + @classmethod + def compact(cls, param): + return "U%s" % param.alternative_name + + @classmethod + def get(cls, style # type: str + ): + # type: (...) -> Callable[[Any], str] + """ + Returns a function that one can use as the `ids` argument in parametrize, applying the given id style. + See https://github.com/smarie/python-pytest-cases/issues/41 + + :param idstyle: + :return: + """ + style = style or 'nostyle' + try: + return getattr(cls, style) + except AttributeError: + raise ValueError("Unknown style: %r" % style) + + class UnionFixtureAlternative(object): - """A special class that should be used to wrap a fixture name""" + """Defines an "alternative", used to parametrize a fixture union""" + __slots__ = 'union_name', 'alternative_name' def __init__(self, - fixture_name, - idstyle # type: IdStyle + union_name, + alternative_name, ): - self.fixture_name = fixture_name - self.idstyle = idstyle + self.union_name = union_name + self.alternative_name = alternative_name # def __str__(self): - # that is maybe too dangerous... - # return self.fixture_name + # # although this would be great to have a default id directly, it may be + # # very confusion for debugging so I prefer that we use id_maker('none') + # # to make this default behaviour explicit and not pollute the debugging process + # return self.alternative_name def __repr__(self): - return "UnionAlternative<%s, idstyle=%s>" % (self.fixture_name, self.idstyle) + return "%s<%s=%s>" % (self.__class__.__name__, self.union_name, self.alternative_name) @staticmethod def to_list_of_fixture_names(alternatives_lst # type: List[UnionFixtureAlternative] ): - return [f.fixture_name for f in alternatives_lst] - - -class IdStyle(Enum): - """ - The enum defining all possible id styles. - """ - none = None - explicit = 'explicit' - compact = 'compact' - - -def apply_id_style(id, union_fixture_name, idstyle): - """ - Applies the id style defined in `idstyle` to the given id. - See https://github.com/smarie/python-pytest-cases/issues/41 - - :param id: - :param union_fixture_name: - :param idstyle: - :return: - """ - if idstyle is IdStyle.none: - return id - elif idstyle is IdStyle.explicit: - return "%s_is_%s" % (union_fixture_name, id) - elif idstyle is IdStyle.compact: - return "U%s" % id - else: - raise ValueError("Invalid id style") + res = [] + for f in alternatives_lst: + if is_marked_parameter_value(f): + f = get_marked_parameter_values(f)[0] + res.append(f.alternative_name) + return res class InvalidParamsList(Exception): @@ -847,7 +894,13 @@ def is_fixture_union_params(params): :return: """ try: - return len(params) >= 1 and isinstance(params[0], UnionFixtureAlternative) + if len(params) < 1: + return False + else: + p0 = params[0] + if is_marked_parameter_value(p0): + p0 = get_marked_parameter_values(p0)[0] + return isinstance(p0, UnionFixtureAlternative) except TypeError: raise InvalidParamsList(params) @@ -865,19 +918,14 @@ def is_used_request(request): return getattr(request, 'param', None) is not NOT_USED -def fixture_alternative_to_str(fixture_alternative, # type: UnionFixtureAlternative - ): - return fixture_alternative.fixture_name - - -def fixture_union(name, - fixtures, - scope="function", - idstyle='explicit', - ids=fixture_alternative_to_str, - unpack_into=None, - autouse=False, - hook=None, +def fixture_union(name, # type: str + fixtures, # type: Iterable[Union[str, Callable]] + scope="function", # type: str + idstyle='explicit', # type: Optional[str] + ids=None, # type: Union[Callable, List[str]] + unpack_into=None, # type: Iterable[str] + autouse=False, # type: bool + hook=None, # type: Callable[[Callable], Callable] **kwargs): """ Creates a fixture that will take all values of the provided fixtures in order. That fixture is automatically @@ -911,26 +959,62 @@ def fixture_union(name, automatically registered in your module. However if you decide to do so make sure that you use the same name. """ caller_module = get_caller_module() - return _fixture_union(caller_module, name, fixtures, scope=scope, idstyle=idstyle, ids=ids, autouse=autouse, + + # test the `fixtures` argument to avoid common mistakes + if not isinstance(fixtures, (tuple, set, list)): + raise TypeError("fixture_union: the `fixtures` argument should be a tuple, set or list") + + # unpack the pytest.param marks + custom_pids, p_marks, fixtures = extract_parameterset_info((name, ), fixtures) + + # get all required fixture names + f_names = [] + for f in fixtures: + # possibly get the fixture name if the fixture symbol was provided + f_names.append(get_fixture_name(f)) + + # create all alternatives and reapply the marks on them + fix_alternatives = [] + f_names_args = [] + for _name, _id, _mark in zip(f_names, custom_pids, p_marks): + # create the alternative object + alternative = UnionFixtureAlternative(union_name=name, alternative_name=_name) + + # remove duplicates in the fixture arguments: each is required only once by the union fixture to create + if _name in f_names_args: + warn("Creating a fixture union %r where two alternatives are the same fixture %r." % (name, _name)) + else: + f_names_args.append(_name) + + # reapply the marks + if _id is not None or (_mark or ()) != (): + alternative = pytest.param(alternative, id=_id, marks=_mark or ()) + fix_alternatives.append(alternative) + + return _fixture_union(caller_module, name, + fix_alternatives=fix_alternatives, unique_fix_alt_names=f_names_args, + scope=scope, idstyle=idstyle, ids=ids, autouse=autouse, hook=hook, unpack_into=unpack_into, **kwargs) def _fixture_union(caller_module, - name, # type: str - fixtures, # type: Iterable[Union[str, Callable]] - idstyle, # type: Optional[Union[str, IdStyle]] - scope="function", # type: str - ids=fixture_alternative_to_str, # type: Union[Callable, List[str]] - unpack_into=None, # type: Iterable[str] - autouse=False, # type: bool - hook=None, # type: Callable + name, # type: str + fix_alternatives, # type: Iterable[UnionFixtureAlternative] + unique_fix_alt_names, # type: List[str] + scope="function", # type: str + idstyle="explicit", # type: str + ids=None, # type: Union[Callable, List[str]] + unpack_into=None, # type: Iterable[str] + autouse=False, # type: bool + hook=None, # type: Callable[[Callable], Callable] **kwargs): """ Internal implementation for fixture_union :param caller_module: :param name: - :param fixtures: + :param fix_alternatives: + :param unique_fix_alt_names: :param idstyle: :param scope: :param ids: @@ -943,49 +1027,32 @@ def _fixture_union(caller_module, :param kwargs: :return: """ - # test the `fixtures` argument to avoid common mistakes - if not isinstance(fixtures, (tuple, set, list)): - raise TypeError("fixture_union: the `fixtures` argument should be a tuple, set or list") + # get the ids generator corresponding to the idstyle + if ids is None: + ids = UnionIdMakers.get(idstyle) - # validate the idstyle - idstyle = IdStyle(idstyle) - - # first get all required fixture names - f_names = [] - for f in fixtures: - # possibly get the fixture name if the fixture symbol was provided - f_names.append(get_fixture_name(f)) - - if len(f_names) < 1: + if len(fix_alternatives) < 1: raise ValueError("Empty fixture unions are not permitted") - # remove duplicates in the fixture arguments - f_names_args = [] - for _fname in f_names: - if _fname in f_names_args: - warn("Creating a fixture union %r where two alternatives are the same fixture %r." % (name, _fname)) - else: - f_names_args.append(_fname) - # then generate the body of our union fixture. It will require all of its dependent fixtures and receive as # a parameter the name of the fixture to use - @with_signature("%s(%s, request)" % (name, ', '.join(f_names_args))) + @with_signature("%s(%s, request)" % (name, ', '.join(unique_fix_alt_names))) def _new_fixture(request, **all_fixtures): if not is_used_request(request): return NOT_USED else: - alternative = request.param - if isinstance(alternative, UnionFixtureAlternative): - fixture_to_use = alternative.fixture_name + _alternative = request.param + if isinstance(_alternative, UnionFixtureAlternative): + fixture_to_use = _alternative.alternative_name return all_fixtures[fixture_to_use] else: raise TypeError("Union Fixture %s received invalid parameter type: %s. Please report this issue." - "" % (name, alternative.__class__)) + "" % (name, _alternative.__class__)) # finally create the fixture per se. # WARNING we do not use pytest.fixture but fixture_plus so that NOT_USED is discarded f_decorator = fixture_plus(scope=scope, - params=[UnionFixtureAlternative(_name, idstyle) for _name in f_names], + params=fix_alternatives, autouse=autouse, ids=ids, hook=hook, **kwargs) fix = f_decorator(_new_fixture) @@ -1000,9 +1067,16 @@ def _new_fixture(request, **all_fixtures): return fix -def _fixture_product(caller_module, name, fixtures_or_values, fixture_positions, - scope="function", ids=fixture_alternative_to_str, - unpack_into=None, autouse=False, hook=None, **kwargs): +def _fixture_product(caller_module, + name, # type: str + fixtures_or_values, + fixture_positions, + scope="function", # type: str + ids=None, # type: Union[Callable, List[str]] + unpack_into=None, # type: Iterable[str] + autouse=False, # type: bool + hook=None, # type: Callable[[Callable], Callable] + **kwargs): """ Internal implementation for fixture products created by pytest parametrize plus. @@ -1029,6 +1103,8 @@ def _fixture_product(caller_module, name, fixtures_or_values, fixture_positions, for f_pos in fixture_positions: # possibly get the fixture name if the fixture symbol was provided f = fixtures_or_values[f_pos] + if isinstance(f, fixture_ref): + f = f.fixture # and remember the position in the tuple f_names[f_pos] = get_fixture_name(f) @@ -1094,7 +1170,143 @@ def pytest_parametrize_plus(*args, return _parametrize_plus(*args, **kwargs) -def parametrize_plus(argnames, argvalues, indirect=False, ids=None, scope=None, hook=None, **kwargs): +class ParamAlternative(UnionFixtureAlternative): + """Defines an "alternative", used to parametrize a fixture union in the context of parametrize_plus""" + __slots__ = ('argnames', ) + + def __init__(self, + union_name, + alternative_name, + argnames, + ): + super(ParamAlternative, self).__init__(union_name=union_name, alternative_name=alternative_name) + self.argnames = argnames + + @property + def argnames_str(self): + return '_'.join(self.argnames) + + +class SingleParamAlternative(ParamAlternative): + """alternative class for single parameter value""" + __slots__ = 'argvalues_index', 'argvalues' + + def __init__(self, + union_name, + alternative_name, + argnames, + argvalues_index, + argvalues + ): + super(SingleParamAlternative, self).__init__(union_name=union_name, alternative_name=alternative_name, + argnames=argnames) + self.argvalues_index = argvalues_index + self.argvalues = argvalues + + def get_id(self): + # return "-".join(self.argvalues) + return mini_idvalset(self.argnames, self.argvalues, idx=self.argvalues_index) + + +class MultiParamAlternative(ParamAlternative): + """alternative class for multiple parameter values""" + __slots__ = 'argvalues_index_from', 'argvalues_index_to' + + def __init__(self, + union_name, + alternative_name, + argnames, + argvalues_index_from, + argvalues_index_to + ): + super(MultiParamAlternative, self).__init__(union_name=union_name, alternative_name=alternative_name, + argnames=argnames) + self.argvalues_index_from = argvalues_index_from + self.argvalues_index_to = argvalues_index_to + + +class FixtureParamAlternative(ParamAlternative): + """alternative class for a single parameter containing a fixture ref""" + __slots__ = 'argvalues_index', + + def __init__(self, + union_name, + alternative_name, + argnames, + argvalues_index, + ): + super(FixtureParamAlternative, self).__init__(union_name=union_name, alternative_name=alternative_name, + argnames=argnames) + self.argvalues_index = argvalues_index + + +class ProductParamAlternative(ParamAlternative): + """alternative class for a single product parameter containing fixture refs""" + __slots__ = 'argvalues_index' + + def __init__(self, + union_name, + alternative_name, + argnames, + argvalues_index, + ): + super(ProductParamAlternative, self).__init__(union_name=union_name, alternative_name=alternative_name, + argnames=argnames) + self.argvalues_index = argvalues_index + + +class ParamIdMakers(object): + """ 'Enum' of id styles for param ids """ + + # @staticmethod + # def nostyle(param): + # return param.alternative_name + + @staticmethod + def explicit(param # type: ParamAlternative + ): + if isinstance(param, SingleParamAlternative): + # return "%s_is_P%s" % (param.param_name, param.argvalues_index) + return "%s_is_%s" % (param.argnames_str, param.get_id()) + elif isinstance(param, MultiParamAlternative): + return "%s_is_P%stoP%s" % (param.argnames_str, param.argvalues_index_from, param.argvalues_index_to - 1) + elif isinstance(param, FixtureParamAlternative): + return "%s_is_%s" % (param.argnames_str, param.alternative_name) + elif isinstance(param, ProductParamAlternative): + return "%s_is_P%s" % (param.argnames_str, param.argvalues_index) + else: + raise TypeError("Unsupported alternative: %r" % param) + + # @staticmethod + # def compact(param): + # return "U%s" % param.alternative_name + + @classmethod + def get(cls, style # type: str + ): + # type: (...) -> Callable[[Any], str] + """ + Returns a function that one can use as the `ids` argument in parametrize, applying the given id style. + See https://github.com/smarie/python-pytest-cases/issues/41 + + :param idstyle: + :return: + """ + style = style or 'nostyle' + try: + return getattr(cls, style) + except AttributeError: + raise ValueError("Unknown style: %r" % style) + + +def parametrize_plus(argnames, + argvalues, + indirect=False, # type: bool + ids=None, # type: Union[Callable, List[str]] + idstyle='explicit', # type: str + scope=None, # type: str + hook=None, # type: Callable[[Callable], Callable] + **kwargs): """ Equivalent to `@pytest.mark.parametrize` but also supports the fact that in argvalues one can include references to fixtures with `fixture_ref()` where can be the fixture name or fixture function. @@ -1107,6 +1319,7 @@ def parametrize_plus(argnames, argvalues, indirect=False, ids=None, scope=None, :param argvalues: :param indirect: :param ids: + :param idstyle: :param scope: :param hook: an optional hook to apply to each fixture function that is created during this call. The hook function will be called everytime a fixture is about to be created. It will receive a single argument (the function @@ -1115,12 +1328,19 @@ def parametrize_plus(argnames, argvalues, indirect=False, ids=None, scope=None, :param kwargs: :return: """ - return _parametrize_plus(argnames, argvalues, indirect=indirect, ids=ids, scope=scope, hook=hook, + return _parametrize_plus(argnames, argvalues, indirect=indirect, ids=ids, idstyle=idstyle, scope=scope, hook=hook, **kwargs) -def _parametrize_plus(argnames, argvalues, indirect=False, ids=None, scope=None, hook=None, - _frame_offset=2, **kwargs): +def _parametrize_plus(argnames, + argvalues, + indirect=False, # type: bool + ids=None, # type: Union[Callable, List[str]] + idstyle='explicit', # type: str + scope=None, # type: str + hook=None, # type: Callable[[Callable], Callable] + _frame_offset=2, + **kwargs): # make sure that we do not destroy the argvalues if it is provided as an iterator try: argvalues = list(argvalues) @@ -1128,8 +1348,14 @@ def _parametrize_plus(argnames, argvalues, indirect=False, ids=None, scope=None, raise InvalidParamsList(argvalues) # get the param names - all_param_names = get_param_argnames_as_list(argnames) - nb_params = len(all_param_names) + initial_argnames = argnames + argnames = get_param_argnames_as_list(argnames) + nb_params = len(argnames) + + # extract all marks and custom ids. + # Do not check consistency of sizes argname/argvalue as a fixture_ref can stand for several argvalues. + marked_argvalues = argvalues + custom_pids, p_marks, argvalues = extract_parameterset_info(argnames, argvalues, check_nb=False) # find if there are fixture references in the values provided fixture_indices = [] @@ -1139,73 +1365,152 @@ def _parametrize_plus(argnames, argvalues, indirect=False, ids=None, scope=None, fixture_indices.append((i, None)) elif nb_params > 1: for i, v in enumerate(argvalues): - try: - j = 0 - fix_pos = [] - for j, _pval in enumerate(v): - if isinstance(_pval, fixture_ref): - fix_pos.append(j) - if len(fix_pos) > 0: - fixture_indices.append((i, fix_pos)) - if j+1 != nb_params: - raise ValueError("Invalid parameter values containing %s items while the number of parameters is %s: " - "%s." % (j+1, nb_params, v)) - except TypeError: - # a fixture ref is - if isinstance(v, fixture_ref): - fixture_indices.append((i, None)) - else: - raise ValueError( - "Invalid parameter values containing %s items while the number of parameters is %s: " - "%s." % (1, nb_params, v)) + if isinstance(v, fixture_ref): + # a fixture ref is used for several parameters at the same time + fixture_indices.append((i, None)) + elif len(v) == 1 and isinstance(v[0], fixture_ref): + # same than above but it was in a pytest.mark + # a fixture ref is used for several parameters at the same time + fixture_indices.append((i, None)) + # unpack it + argvalues[i] = v[0] + else: + # check for consistency + if len(v) != len(argnames): + raise ValueError("Inconsistent number of values in pytest parametrize: %s items found while the " + "number of parameters is %s: %s." % (len(v), len(argnames), v)) + + # let's dig into the tuple + fix_pos_list = [j for j, _pval in enumerate(v) if isinstance(_pval, fixture_ref)] + if len(fix_pos_list) > 0: + # there is at least one fixture ref inside the tuple + fixture_indices.append((i, fix_pos_list)) + del i if len(fixture_indices) == 0: - # no fixture reference: do as usual - return pytest.mark.parametrize(argnames, argvalues, indirect=indirect, ids=ids, scope=scope, **kwargs) + # no fixture reference: shortcut, do as usual (note that the hook wont be called since no fixture is created) + return pytest.mark.parametrize(initial_argnames, marked_argvalues, indirect=indirect, + ids=ids, scope=scope, **kwargs) else: # there are fixture references: we have to create a specific decorator caller_module = get_caller_module(frame_offset=_frame_offset) + param_names_str = '_'.join(argnames).replace(' ', '') + + def _make_idfun_for_params(argnames, nb_positions): + """ + Creates an id creating function that will use 'argnames' as the argnames + instead of the one(s) received by pytest. We use this in the case of param fixture + creation because on one side we need a unique fixture name so it is big and horrible, + but on the other side we want the id to rather reflect the simple argnames, no that fixture name. - def _create_param_fixture(from_i, to_i, p_names, test_func_name, hook): + :param argnames: + :param nb_positions: + :return: + """ + # create a new make id function with its own local counter of parameter + def _tmp_make_id(argvalues): + _tmp_make_id._i += 1 + if _tmp_make_id._i >= nb_positions: + raise ValueError("Internal error, please report") + argvalues = argvalues if len(argnames) > 1 else (argvalues,) + return mini_idvalset(argnames, argvalues, idx=_tmp_make_id._i) + + # init its positions counter + _tmp_make_id._i = -1 + return _tmp_make_id + + def _create_params_alt(test_func_name, union_name, from_i, to_i, hook): """ Routine that will be used to create a parameter fixture for argvalues between prev_i and i""" - selected_argvalues = argvalues[from_i:to_i] - try: - # an explicit list of ids - selected_ids = ids[from_i:to_i] - except TypeError: - # a callable to create the ids - selected_ids = ids - # default behaviour is not the same between pytest params and pytest fixtures - if selected_ids is None: - # selected_ids = ['-'.join([str(_v) for _v in v]) for v in selected_argvalues] - selected_ids = get_test_ids_from_param_values(all_param_names, selected_argvalues) + single_param = (to_i == from_i + 1) + + if single_param: + i = from_i + + # 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) + # 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 the alternative + argvals = (argvalues[i],) if nb_params == 1 else argvalues[i] + p_fix_alt = SingleParamAlternative(union_name=union_name, alternative_name=p_fix_name, + argnames=argnames, argvalues_index=i, argvalues=argvals) + # Finally copy the custom id/marks on the ParamAlternative if any + if is_marked_parameter_value(marked_argvalues[i]): + p_fix_alt = pytest.param(p_fix_alt, id=marked_argvalues[i].id, marks=marked_argvalues[i].marks) - if to_i == from_i + 1: - p_names_with_idx = "%s_is_%s" % (p_names, from_i) else: - p_names_with_idx = "%s_is_%sto%s" % (p_names, from_i, to_i - 1) + # 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) + + # If an explicit list of ids was provided, slice it. Otherwise use the provided callable + try: + p_ids = ids[from_i:to_i] + except TypeError: + # callable ? otherwise default to a customized id maker that replaces the fixture name + # that we use (p_fix_name) with a simpler name in the ids (just the argnames) + p_ids = ids or _make_idfun_for_params(argnames=argnames, nb_positions=(to_i - from_i)) + + # Create the fixture that will take all these parameter values + # That fixture WILL be parametrized, this is why we propagate the p_ids and use the marked values + _create_param_fixture(caller_module, argname=p_fix_name, argvalues=marked_argvalues[from_i:to_i], + ids=p_ids, hook=hook) + + # Create the corresponding alternative + p_fix_alt = MultiParamAlternative(union_name=union_name, alternative_name=p_fix_name, argnames=argnames, + argvalues_index_from=from_i, argvalues_index_to=to_i) + # no need to copy the custom id/marks to the ParamAlternative: they were passed above already + + return p_fix_name, p_fix_alt + + def _create_fixture_ref_alt(union_name, i): + # Get the referenced fixture name + f_fix_name = get_fixture_name(argvalues[i].fixture) + + # Create the alternative + f_fix_alt = FixtureParamAlternative(union_name=union_name, alternative_name=f_fix_name, + argnames=argnames, argvalues_index=i) + # Finally copy the custom id/marks on the ParamAlternative if any + if is_marked_parameter_value(marked_argvalues[i]): + f_fix_alt = pytest.param(f_fix_alt, id=marked_argvalues[i].id, marks=marked_argvalues[i].marks) + + return f_fix_name, f_fix_alt + + def _create_fixture_ref_product(union_name, i, fixture_ref_positions, test_func_name, hook): + + # If an explicit list of ids was provided, slice it. Otherwise use the provided callable + try: + p_ids = ids[i] + except TypeError: + p_ids = ids # callable + + # values to use: + p_values = argvalues[i] - # now create a unique fixture name - p_fix_name = "%s_%s" % (test_func_name, p_names_with_idx) + # 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) - param_fix = _param_fixture(caller_module, argname=p_fix_name, argvalues=selected_argvalues, - ids=selected_ids, hook=hook) - return param_fix, p_names_with_idx + # Create the fixture + new_product_fix = _fixture_product(caller_module, name=p_fix_name, fixtures_or_values=p_values, + fixture_positions=fixture_ref_positions, hook=hook, ids=p_ids) - def _create_fixture_product(argvalue_i, fixture_ref_positions, param_names_str, test_func_name, hook): - # do not use base name - we dont care if there is another in the same module, it will still be more readable - p_fix_name = "%s_%s__fixtureproduct__%s" % (test_func_name, param_names_str, argvalue_i) - p_fix_name = check_name_available(caller_module, p_fix_name, if_name_exists=CHANGE, - caller=parametrize_plus) - # unpack the fixture references - _vtuple = argvalues[argvalue_i] - fixtures_or_values = tuple(v.fixture if i in fixture_ref_positions else v for i, v in enumerate(_vtuple)) - product_fix = _fixture_product(caller_module, p_fix_name, fixtures_or_values, fixture_ref_positions, - hook=hook) - return product_fix + # Create the corresponding alternative + p_fix_alt = ProductParamAlternative(union_name=union_name, alternative_name=p_fix_name, + argnames=argnames, argvalues_index=i) + # copy the custom id/marks to the ParamAlternative if any + if is_marked_parameter_value(marked_argvalues[i]): + p_fix_alt = pytest.param(p_fix_alt, id=marked_argvalues[i].id, marks=marked_argvalues[i].marks) + + return p_fix_name, p_fix_alt # then create the decorator def parametrize_plus_decorate(test_func): @@ -1216,81 +1521,119 @@ def parametrize_plus_decorate(test_func): :param test_func: :return: """ + test_func_name = test_func.__name__ + + # Are there explicit ids provided ? + try: + if len(ids) != len(argvalues): + raise ValueError("Explicit list of `ids` provided has a different length (%s) than the number of " + "parameter sets (%s)" % (len(ids), len(argvalues))) + explicit_ids_to_use = [] + except TypeError: + explicit_ids_to_use = None + # first check if the test function has the parameters as arguments old_sig = signature(test_func) - for p in all_param_names: + for p in argnames: if p not in old_sig.parameters: raise ValueError("parameter '%s' not found in test function signature '%s%s'" - "" % (p, test_func.__name__, old_sig)) + "" % (p, test_func_name, old_sig)) # The name for the final "union" fixture # style_template = "%s_param__%s" - param_names_str = argnames.replace(' ', '').replace(',', '_') - style_template = "%s_%s" - fixture_union_name = style_template % (test_func.__name__, param_names_str) + 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, caller=parametrize_plus) # Retrieve (if ref) or create (for normal argvalues) the fixtures that we will union - # TODO important note: we could either wish to create one fixture for parameter value or to create 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 ? - fixtures_to_union = [] - fixtures_to_union_names_for_ids = [] + fixture_alternatives = [] prev_i = -1 for i, j_list in fixture_indices: + # A/ Is there any non-empty group of 'normal' parameters before the fixture_ref at ? If so, handle. if i > prev_i + 1: - # there was a non-empty group of 'normal' parameters before the fixture_ref at . # create a new "param" fixture parametrized with all of that consecutive group. - param_fix, _id_for_fix = _create_param_fixture(prev_i + 1, i, param_names_str, test_func.__name__, - hook=hook) - fixtures_to_union.append(param_fix) - fixtures_to_union_names_for_ids.append(_id_for_fix) - + # TODO important note: we could either wish to create one fixture for parameter value or to create + # 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, + 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): + explicit_ids_to_use.append(ids[prev_i + 1]) + else: + # the ids provided by the user are propagated to the params of this fix, so we need an id + explicit_ids_to_use.append(ParamIdMakers.explicit(p_fix_alt)) + + # B/ Now handle the fixture ref at position if j_list is None: - # add the fixture referenced with `fixture_ref` - referenced_fixture = argvalues[i].fixture - fixtures_to_union.append(referenced_fixture) - id_for_fixture = apply_id_style(get_fixture_name(referenced_fixture), param_names_str, IdStyle.explicit) - fixtures_to_union_names_for_ids.append(id_for_fixture) + # argvalues[i] contains a single argvalue that is a fixture_ref : add the referenced fixture + f_fix_name, f_fix_alt = _create_fixture_ref_alt(union_name=fixture_union_name, i=i) + fixture_alternatives.append((f_fix_name, f_fix_alt)) + if explicit_ids_to_use is not None: + explicit_ids_to_use.append(ids[i]) + else: - # argvalues[i] is a tuple of argvalues, some of them being fixture_ref. create a fixture refering to all of them - prod_fix = _create_fixture_product(i, j_list, param_names_str, test_func.__name__, - hook=hook) - fixtures_to_union.append(prod_fix) - _id_product = "fixtureproduct__%s" % i - id_for_fixture = apply_id_style(_id_product, param_names_str, IdStyle.explicit) - fixtures_to_union_names_for_ids.append(id_for_fixture) + # 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, + fixture_ref_positions=j_list, + test_func_name=test_func_name, hook=hook) + fixture_alternatives.append((prod_fix_name, prod_fix_alt)) + if explicit_ids_to_use is not None: + explicit_ids_to_use.append(ids[i]) + prev_i = i - # handle last consecutive group of normal parameters, if any + # C/ handle last consecutive group of normal parameters, if any i = len(argvalues) if i > prev_i + 1: - param_fix, _id_for_fix = _create_param_fixture(prev_i + 1, i, param_names_str, test_func.__name__, - hook=hook) - fixtures_to_union.append(param_fix) - fixtures_to_union_names_for_ids.append(_id_for_fix) + 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) + fixture_alternatives.append((p_fix_name, p_fix_alt)) + if explicit_ids_to_use is not None: + if isinstance(p_fix_alt, SingleParamAlternative): + explicit_ids_to_use.append(ids[prev_i + 1]) + else: + # the ids provided by the user are propagated to the params of this fix, so we need an id + explicit_ids_to_use.append(ParamIdMakers.explicit(p_fix_alt)) # TO DO if fixtures_to_union has length 1, simplify ? >> No, we leave such "optimization" to the end user + # consolidate the list of alternatives + fix_alternatives = tuple(a[1] for a in fixture_alternatives) + + # and the list of their names. Duplicates should be removed here + fix_alt_names = [] + for a, alt in fixture_alternatives: + if a not in fix_alt_names: + fix_alt_names.append(a) + else: + # this should only happen when the alternative is directly a fixture reference + assert isinstance(alt, FixtureParamAlternative), \ + "Created fixture names are not unique, please report" + # Finally create a "main" fixture with a unique name for this test function + # note: the function automatically registers it in the module - # note 2: idstyle is set to None because we provide an explicit enough list of ids - big_param_fixture = _fixture_union(caller_module, fixture_union_name, fixtures_to_union, idstyle=None, - ids=fixtures_to_union_names_for_ids, hook=hook) + big_param_fixture = _fixture_union(caller_module, name=fixture_union_name, + fix_alternatives=fix_alternatives, unique_fix_alt_names=fix_alt_names, + ids=explicit_ids_to_use or ids or ParamIdMakers.get(idstyle), hook=hook) # --create the new test function's signature that we want to expose to pytest # it is the same than existing, except that we want to replace all parameters with the new fixture # first check where we should insert the new parameters (where is the first param we remove) for _first_idx, _n in enumerate(old_sig.parameters): - if _n in all_param_names: + if _n in argnames: break # then remove all parameters that will be replaced by the new fixture - new_sig = remove_signature_parameters(old_sig, *all_param_names) + new_sig = remove_signature_parameters(old_sig, *argnames) # finally insert the new fixture in that position. Indeed we can not insert first or last, because # 'self' arg (case of test class methods) should stay first and exec order should be preserved when possible new_sig = add_signature_parameters(new_sig, custom_idx=_first_idx, - custom=Parameter(fixture_union_name, kind=Parameter.POSITIONAL_OR_KEYWORD)) + custom=Parameter(fixture_union_name, + kind=Parameter.POSITIONAL_OR_KEYWORD)) # --Finally create the fixture function, a wrapper of user-provided fixture with the new signature def replace_paramfixture_with_values(kwargs): @@ -1298,10 +1641,10 @@ def replace_paramfixture_with_values(kwargs): encompassing_fixture = kwargs.pop(fixture_union_name) # and add instead the parameter values if nb_params > 1: - for i, p in enumerate(all_param_names): + for i, p in enumerate(argnames): kwargs[p] = encompassing_fixture[i] else: - kwargs[all_param_names[0]] = encompassing_fixture + kwargs[argnames[0]] = encompassing_fixture # return return kwargs diff --git a/pytest_cases/main_params.py b/pytest_cases/main_params.py index 111d8d58..b1b4f7eb 100644 --- a/pytest_cases/main_params.py +++ b/pytest_cases/main_params.py @@ -8,7 +8,7 @@ from decopatch import function_decorator, DECORATED, with_parenthesis -from six import with_metaclass, string_types +from .mini_six import with_metaclass, string_types import pytest try: # python 3.3+ diff --git a/pytest_cases/mini_six.py b/pytest_cases/mini_six.py new file mode 100644 index 00000000..09049345 --- /dev/null +++ b/pytest_cases/mini_six.py @@ -0,0 +1,25 @@ +import sys + +PY3 = sys.version_info[0] == 3 +PY34 = sys.version_info[0:2] >= (3, 4) + +if PY3: + string_types = str, +else: + string_types = basestring, + + +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 + # metaclass for one level of class instantiation that replaces itself with + # the actual metaclass. + class metaclass(type): + + def __new__(cls, name, this_bases, d): + return meta(name, bases, d) + + @classmethod + def __prepare__(cls, name, this_bases): + return meta.__prepare__(name, bases) + return type.__new__(metaclass, 'temporary_class', (), {}) diff --git a/pytest_cases/plugin.py b/pytest_cases/plugin.py index 23ece116..c8a69194 100644 --- a/pytest_cases/plugin.py +++ b/pytest_cases/plugin.py @@ -4,13 +4,13 @@ from warnings import warn from functools import partial -from six import string_types +from .mini_six import string_types import pytest from pytest_cases.common import get_pytest_nodeid, get_pytest_function_scopenum, \ is_function_node, get_param_names, get_pytest_scopenum, get_param_argnames_as_list -from pytest_cases.main_fixtures import NOT_USED, is_fixture_union_params, UnionFixtureAlternative, apply_id_style +from pytest_cases.main_fixtures import NOT_USED, is_fixture_union_params, UnionFixtureAlternative try: # python 3.3+ from inspect import signature @@ -329,6 +329,7 @@ def _build_closure(self, if not fixturedefs: # fixture without definition: add it self.add_required_fixture(fixname, None) + continue else: # the actual definition is the last one _fixdef = fixturedefs[-1] @@ -352,6 +353,7 @@ def _build_closure(self, # empty the pending because all of them have been propagated on all children with their dependencies pending_fixture_names = [] + continue else: # normal fixture @@ -363,6 +365,7 @@ def _build_closure(self, # pending_fixture_names += dependencies # - prepend: makes much more sense pending_fixture_names = list(dependencies) + pending_fixture_names + continue # ------ tools to add new fixture names during closure construction @@ -982,20 +985,11 @@ def _process_node(self, current_node, pending, calls): ids=p_to_apply.ids, scope=p_to_apply.scope, **p_to_apply.kwargs) - # Change the ids by applying the style defined in the corresponding alternative - for callspec in calls: - # TODO right now only the idstyle defined in the first alternative is used. But it cant be - # different from the other ones for now because of the way fixture_union is built. - # Maybe the best would be to remove this and apply the id style when fixture is created. - callspec._idlist[-1] = apply_id_style(callspec._idlist[-1], - p_to_apply.union_fixture_name, - p_to_apply.alternative_names[0].idstyle) - # now move to the children nodes_children = [None] * len(calls) for i in range(len(calls)): active_alternative = calls[i].params[p_to_apply.union_fixture_name] - child_node = current_node.children[active_alternative.fixture_name] + child_node = current_node.children[active_alternative.alternative_name] child_pending = pending.copy() # place the childs parameter in the first position if it is in the list diff --git a/pytest_cases/tests/fixtures/test_fixture_in_parametrize_basic.py b/pytest_cases/tests/fixtures/test_fixture_ref_basic1.py similarity index 81% rename from pytest_cases/tests/fixtures/test_fixture_in_parametrize_basic.py rename to pytest_cases/tests/fixtures/test_fixture_ref_basic1.py index f6e5ec0b..1d350b09 100644 --- a/pytest_cases/tests/fixtures/test_fixture_in_parametrize_basic.py +++ b/pytest_cases/tests/fixtures/test_fixture_ref_basic1.py @@ -1,5 +1,5 @@ import pytest -from pytest_cases import pytest_parametrize_plus, pytest_fixture_plus, fixture_ref +from pytest_cases import pytest_parametrize_plus, pytest_fixture_plus, fixture_ref, parametrize_plus from pytest_cases.tests.conftest import global_fixture @@ -22,13 +22,24 @@ def test_prints(main_msg, ending): def test_synthesis(module_results_dct): - assert list(module_results_dct) == ['test_prints[main_msg_is_0-nothing-?]', - 'test_prints[main_msg_is_0-nothing-!]', + assert list(module_results_dct) == ['test_prints[main_msg_is_nothing-?]', + 'test_prints[main_msg_is_nothing-!]', 'test_prints[main_msg_is_world_str-?]', 'test_prints[main_msg_is_world_str-!]', 'test_prints[main_msg_is_greetings-who_is_world_str-?]', 'test_prints[main_msg_is_greetings-who_is_world_str-!]', - 'test_prints[main_msg_is_greetings-who_is_1-you-?]', - 'test_prints[main_msg_is_greetings-who_is_1-you-!]', + 'test_prints[main_msg_is_greetings-who_is_you-?]', + 'test_prints[main_msg_is_greetings-who_is_you-!]', 'test_prints[main_msg_is_global_fixture-?]', 'test_prints[main_msg_is_global_fixture-!]'] + + +@pytest.fixture +def c(): + return 3, 2 + + +@parametrize_plus("a,b", [fixture_ref(c)]) +def test_foo(a, b): + """here the fixture is used for both parameters at the same time""" + assert (a, b) == (3, 2) diff --git a/pytest_cases/tests/fixtures/test_fixture_in_parametrize.py b/pytest_cases/tests/fixtures/test_fixture_ref_basic2.py similarity index 86% rename from pytest_cases/tests/fixtures/test_fixture_in_parametrize.py rename to pytest_cases/tests/fixtures/test_fixture_ref_basic2.py index e2ac53b8..d7e689fa 100644 --- a/pytest_cases/tests/fixtures/test_fixture_in_parametrize.py +++ b/pytest_cases/tests/fixtures/test_fixture_ref_basic2.py @@ -31,8 +31,8 @@ def test_foo(arg, bar): def test_synthesis(module_results_dct): - assert list(module_results_dct) == ['test_foo[arg_is_0-z-bar]', + assert list(module_results_dct) == ['test_foo[arg_is_z-bar]', 'test_foo[arg_is_a-bar]', 'test_foo[arg_is_b-second_letter_is_a-bar]', - 'test_foo[arg_is_b-second_letter_is_1-o-bar]', - 'test_foo[arg_is_3-o-bar]'] + 'test_foo[arg_is_b-second_letter_is_o-bar]', + 'test_foo[arg_is_o-bar]'] diff --git a/pytest_cases/tests/fixtures/test_fixture_in_parametrize_tuple.py b/pytest_cases/tests/fixtures/test_fixture_ref_basic3_tuples.py similarity index 56% rename from pytest_cases/tests/fixtures/test_fixture_in_parametrize_tuple.py rename to pytest_cases/tests/fixtures/test_fixture_ref_basic3_tuples.py index 90b7df5f..cf8ce47a 100644 --- a/pytest_cases/tests/fixtures/test_fixture_in_parametrize_tuple.py +++ b/pytest_cases/tests/fixtures/test_fixture_ref_basic3_tuples.py @@ -31,15 +31,15 @@ def test_prints(p, q): def test_synthesis(module_results_dct): - assert list(module_results_dct) == ['test_prints[p_q_is_0-a-1]', - 'test_prints[p_q_is_fixtureproduct__1-b]', - 'test_prints[p_q_is_fixtureproduct__1-c]', - 'test_prints[p_q_is_fixtureproduct__2-b-0]', - 'test_prints[p_q_is_fixtureproduct__2-b--1]', - 'test_prints[p_q_is_fixtureproduct__2-c-0]', - 'test_prints[p_q_is_fixtureproduct__2-c--1]', - 'test_prints[p_q_is_fixtureproduct__3-b]', - 'test_prints[p_q_is_fixtureproduct__3-c]', - "test_prints[p_q_is_my_tuple-('d', 3)]", - "test_prints[p_q_is_my_tuple-('e', 4)]" + assert list(module_results_dct) == ['test_prints[p_q_is_a-1]', + 'test_prints[p_q_is_P1-b]', + 'test_prints[p_q_is_P1-c]', + 'test_prints[p_q_is_P2-b-0]', + 'test_prints[p_q_is_P2-b--1]', + 'test_prints[p_q_is_P2-c-0]', + 'test_prints[p_q_is_P2-c--1]', + 'test_prints[p_q_is_P3-b]', + 'test_prints[p_q_is_P3-c]', + "test_prints[p_q_is_my_tuple-val0]", + "test_prints[p_q_is_my_tuple-val1]" ] diff --git a/pytest_cases/tests/fixtures/test_fixture_ref_basic4_ids.py b/pytest_cases/tests/fixtures/test_fixture_ref_basic4_ids.py new file mode 100644 index 00000000..1e6517c4 --- /dev/null +++ b/pytest_cases/tests/fixtures/test_fixture_ref_basic4_ids.py @@ -0,0 +1,36 @@ +import pytest + +from pytest_cases import parametrize_plus, pytest_fixture_plus, fixture_ref + + +@pytest.fixture +def a(): + return 'A', 'AA' + + +@pytest_fixture_plus +@pytest.mark.parametrize('arg', [1, 2]) +def b(arg): + return "B%s" % arg + + +@parametrize_plus("arg1,arg2", [('1', None), + (None, '2'), + fixture_ref('a'), + ('4', '4'), + ('3', fixture_ref('b')) + ]) +def test_foo(arg1, arg2): + print(arg1, arg2) + + +def test_synthesis(module_results_dct): + """See https://github.com/smarie/python-pytest-cases/issues/86""" + assert list(module_results_dct) == [ + 'test_foo[arg1_arg2_is_P0toP1-1-None]', + 'test_foo[arg1_arg2_is_P0toP1-None-2]', + 'test_foo[arg1_arg2_is_a]', + 'test_foo[arg1_arg2_is_4-4]', + 'test_foo[arg1_arg2_is_P4-1]', + 'test_foo[arg1_arg2_is_P4-2]' + ] diff --git a/pytest_cases/tests/fixtures/test_fixture_ref_custom1.py b/pytest_cases/tests/fixtures/test_fixture_ref_custom1.py new file mode 100644 index 00000000..77cfaf04 --- /dev/null +++ b/pytest_cases/tests/fixtures/test_fixture_ref_custom1.py @@ -0,0 +1,72 @@ +from distutils.version import LooseVersion + +import pytest + +from pytest_harvest import saved_fixture, get_session_synthesis_dct +from pytest_cases import parametrize_plus, fixture_ref, pytest_fixture_plus + + +# pytest.param is not available in all versions +if LooseVersion(pytest.__version__) >= LooseVersion('3.0.0'): + + @pytest.fixture + @saved_fixture + def a(): + return 'a' + + + @pytest_fixture_plus + @saved_fixture + @pytest.mark.parametrize('i', [5, 6]) + def b(i): + return 'b%s' % i + + + @parametrize_plus('arg', [pytest.param('c'), + pytest.param(fixture_ref(a)), + fixture_ref(b)], + hook=saved_fixture) + def test_fixture_ref1(arg): + assert arg in ['a', 'b5', 'b6', 'c'] + + + def test_synthesis1(request, fixture_store): + results_dct1 = get_session_synthesis_dct(request, filter=test_fixture_ref1, test_id_format='function', + fixture_store=fixture_store, flatten=True) + assert [(k, v['test_fixture_ref1_arg']) for k, v in results_dct1.items()] == [ + ('test_fixture_ref1[arg_is_c]', 'c'), + ('test_fixture_ref1[arg_is_a]', 'a'), + ('test_fixture_ref1[arg_is_b-5]', 'b5'), + ('test_fixture_ref1[arg_is_b-6]', 'b6'), + ] + + + # ------------- + + + @pytest.fixture + @saved_fixture + def c(): + return 'c', 'd' + + + @parametrize_plus('foo,bar', [pytest.param(fixture_ref(a), 1), + (2, fixture_ref(b)), + pytest.param(fixture_ref(c)), + fixture_ref(c) + ]) + def test_fixture_ref2(foo, bar): + assert foo in ['a', 2, 'c'] + assert bar in {'a': (1, ), 2: ('b5', 'b6'), 'c': ('d',)}[foo] + + + def test_synthesis2(request, fixture_store): + results_dct2 = get_session_synthesis_dct(request, filter=test_fixture_ref2, test_id_format='function', + fixture_store=fixture_store, flatten=True) + assert list(results_dct2) == [ + 'test_fixture_ref2[foo_bar_is_P0]', + 'test_fixture_ref2[foo_bar_is_P1-5]', + 'test_fixture_ref2[foo_bar_is_P1-6]', + 'test_fixture_ref2[foo_bar_is_c0]', + 'test_fixture_ref2[foo_bar_is_c1]' + ] diff --git a/pytest_cases/tests/fixtures/test_fixture_ref_custom2.py b/pytest_cases/tests/fixtures/test_fixture_ref_custom2.py new file mode 100644 index 00000000..a3239d6d --- /dev/null +++ b/pytest_cases/tests/fixtures/test_fixture_ref_custom2.py @@ -0,0 +1,36 @@ +from distutils.version import LooseVersion + +import pytest +from pytest_cases import parametrize_plus, fixture_ref + +# pytest.param is not available in all versions +if LooseVersion(pytest.__version__) >= LooseVersion('3.0.0'): + @pytest.fixture + def a(): + return 'a' + + + @pytest.fixture + def b(): + return 'b' + + + @parametrize_plus('arg', [pytest.param("a", marks=pytest.mark.skipif("5>4")), + fixture_ref(b)]) + def test_mark(arg): + assert arg in ['a', 'b'] + + + @parametrize_plus('arg', [pytest.param("a", id="testID"), + fixture_ref(b)]) + def test_id(arg): + assert arg in ['a', 'b'] + + + def test_synthesis(module_results_dct): + # make sure the id and skip mark were taken into account + assert list(module_results_dct) == [ + 'test_mark[arg_is_b]', + 'test_id[testID]', + 'test_id[arg_is_b]' + ] diff --git a/pytest_cases/tests/fixtures/test_fixture_ref_custom3.py b/pytest_cases/tests/fixtures/test_fixture_ref_custom3.py new file mode 100644 index 00000000..0fc1aca1 --- /dev/null +++ b/pytest_cases/tests/fixtures/test_fixture_ref_custom3.py @@ -0,0 +1,39 @@ +from distutils.version import LooseVersion + +import pytest +from pytest_cases import parametrize_plus, fixture_ref + +# pytest.param is not available in all versions +if LooseVersion(pytest.__version__) >= LooseVersion('3.0.0'): + @pytest.fixture + def a(): + return 'a' + + + @pytest.fixture(params=['r', 't'], ids="b={}".format) + def b(request): + return "b%s" % request.param + + @parametrize_plus('foo', [1, + fixture_ref(b), + pytest.param('t'), + pytest.param('r', id='W'), + 3, + pytest.param(fixture_ref(a)), + fixture_ref(a) + ], ids=[str(i) for i in range(7)]) + def test_id(foo): + pass + + def test_synthesis(module_results_dct): + # make sure the id and skip mark were taken into account + assert list(module_results_dct) == [ + 'test_id[0]', + 'test_id[1-b=r]', + 'test_id[1-b=t]', + 'test_id[foo_is_P2toP4-2]', + 'test_id[foo_is_P2toP4-W]', + 'test_id[foo_is_P2toP4-4]', + 'test_id[5]', + 'test_id[6]' + ] diff --git a/pytest_cases/tests/fixtures/test_fixture_union_custom_mark.py b/pytest_cases/tests/fixtures/test_fixture_union_custom_mark.py new file mode 100644 index 00000000..92a17914 --- /dev/null +++ b/pytest_cases/tests/fixtures/test_fixture_union_custom_mark.py @@ -0,0 +1,34 @@ +from distutils.version import LooseVersion + +import pytest + +from pytest_cases import param_fixture, fixture_union + +# pytest.param is not available in all versions +if LooseVersion(pytest.__version__) >= LooseVersion('3.0.0'): + a = param_fixture("a", [1, + pytest.param(2, id='22'), + pytest.param(3, marks=pytest.mark.skip) + ]) + + + b = param_fixture("b", [3, 4]) + + + c = fixture_union('c', [pytest.param('a', id='A'), + pytest.param(b, marks=pytest.mark.skip) + ], + ids=['ignored', 'B'], + ) + + + def test_foo(c): + pass + + + def test_synthesis(module_results_dct): + # TODO most probably the skip mark on b seeems to mess with the union behaviour. + assert list(module_results_dct) == [ + 'test_foo[A-1]', + 'test_foo[A-22]', + ] diff --git a/pytest_cases/tests/fixtures/test_fixture_union_ids.py b/pytest_cases/tests/fixtures/test_fixture_union_ids.py index 32d9bb3d..64a4d502 100644 --- a/pytest_cases/tests/fixtures/test_fixture_union_ids.py +++ b/pytest_cases/tests/fixtures/test_fixture_union_ids.py @@ -3,20 +3,21 @@ a = param_fixture("a", [1, 2]) b = param_fixture("b", [3, 4]) -c = fixture_union('c', ['a', b], ids=['A', 'B'], idstyle='explicit') +c = fixture_union('c', ['a', b], ids=['c=A', 'c=B']) d = fixture_union('d', ['a'], idstyle='compact') e = fixture_union('e', ['a'], idstyle=None) +f = fixture_union('f', ['a']) -def test_the_ids(c, d, e): +def test_the_ids(c, d, e, f): pass def test_synthesis(module_results_dct): - assert list(module_results_dct) == ['test_the_ids[c_is_A-1-Ua-a]', - 'test_the_ids[c_is_A-2-Ua-a]', - 'test_the_ids[c_is_B-3-Ua-1-a]', - 'test_the_ids[c_is_B-3-Ua-2-a]', - 'test_the_ids[c_is_B-4-Ua-1-a]', - 'test_the_ids[c_is_B-4-Ua-2-a]', + assert list(module_results_dct) == ['test_the_ids[c=A-1-Ua-a-f_is_a]', + 'test_the_ids[c=A-2-Ua-a-f_is_a]', + 'test_the_ids[c=B-3-Ua-1-a-f_is_a]', + 'test_the_ids[c=B-3-Ua-2-a-f_is_a]', + 'test_the_ids[c=B-4-Ua-1-a-f_is_a]', + 'test_the_ids[c=B-4-Ua-2-a-f_is_a]', ] diff --git a/pytest_cases/tests/fixtures/test_fixtures_paramfixtures_marks.py b/pytest_cases/tests/fixtures/test_fixtures_paramfixtures_marks.py new file mode 100644 index 00000000..369b69c6 --- /dev/null +++ b/pytest_cases/tests/fixtures/test_fixtures_paramfixtures_marks.py @@ -0,0 +1,21 @@ +from distutils.version import LooseVersion + +import pytest + +from pytest_cases import param_fixture + +# pytest.param - not available in all versions +if LooseVersion(pytest.__version__) >= LooseVersion('3.0.0'): + a = param_fixture("a", [1, + pytest.param(2, id='22'), + pytest.param(3, marks=pytest.mark.skip) + ]) + + + def test_foo(a): + pass + + + def test_synthesis(module_results_dct): + # id taken into account as well as skip mark (module_results_dct filters on non-skipped) + assert list(module_results_dct) == ['test_foo[1]', 'test_foo[22]'] diff --git a/pytest_cases/tests/issues/test_issue_fixture_union1.py b/pytest_cases/tests/issues/test_issue_fixture_union1.py index 1a78ff14..0b69aee3 100644 --- a/pytest_cases/tests/issues/test_issue_fixture_union1.py +++ b/pytest_cases/tests/issues/test_issue_fixture_union1.py @@ -19,6 +19,6 @@ def test_foo(u): def test_synthesis(module_results_dct): if LooseVersion(pytest.__version__) < LooseVersion('3.0.0'): # the way to make ids uniques in case of duplicates was different in old pytest - assert list(module_results_dct) == ['test_foo[u_is_0a]', 'test_foo[u_is_1a]'] + assert list(module_results_dct) == ['test_foo[0u_is_a]', 'test_foo[1u_is_a]'] else: assert list(module_results_dct) == ['test_foo[u_is_a0]', 'test_foo[u_is_a1]'] diff --git a/pytest_cases/tests/issues/test_issue_pytest_70.py b/pytest_cases/tests/issues/test_issue_pytest_70.py index 39aa4092..55bf360c 100644 --- a/pytest_cases/tests/issues/test_issue_pytest_70.py +++ b/pytest_cases/tests/issues/test_issue_pytest_70.py @@ -26,6 +26,6 @@ def test_get_or_create_book(name): def test_synthesis(module_results_dct): assert list(module_results_dct) == ['test_get_or_create_book[name_is_book1-A]', 'test_get_or_create_book[name_is_book1-B]', - 'test_get_or_create_book[name_is_1to2-hi]', - 'test_get_or_create_book[name_is_1to2-ih]', + 'test_get_or_create_book[name_is_P1toP2-hi]', + 'test_get_or_create_book[name_is_P1toP2-ih]', 'test_get_or_create_book[name_is_book2]'] diff --git a/setup.py b/setup.py index 83b17822..612d6d05 100644 --- a/setup.py +++ b/setup.py @@ -13,8 +13,9 @@ from setuptools_scm import get_version # noqa: E402 # *************** Dependencies ********* -INSTALL_REQUIRES = ['wrapt', 'decopatch', 'makefun>=1.7', 'functools32;python_version<"3.2"', - 'funcsigs;python_version<"3.3"', 'enum34;python_version<"3.4"', 'six'] +INSTALL_REQUIRES = ['decopatch', 'makefun>=1.7', + 'functools32;python_version<"3.2"', 'funcsigs;python_version<"3.3"', + ] DEPENDENCY_LINKS = [] SETUP_REQUIRES = ['pytest-runner', 'setuptools_scm'] TESTS_REQUIRE = ['pytest', 'pytest-logging', 'pytest-steps', 'pytest-harvest']