diff --git a/changelog/1857.improvement.rst b/changelog/1857.improvement.rst new file mode 100644 index 00000000000..9a8ce90f54f --- /dev/null +++ b/changelog/1857.improvement.rst @@ -0,0 +1 @@ +``pytest.mark.parametrize`` accepts integers for ``ids`` again, converting it to strings. diff --git a/changelog/759.improvement.rst b/changelog/759.improvement.rst new file mode 100644 index 00000000000..83ace7485e2 --- /dev/null +++ b/changelog/759.improvement.rst @@ -0,0 +1 @@ +``pytest.mark.parametrize`` supports iterators and generators for ``ids``. diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py index 0ccd8141dd9..67fc91ac351 100644 --- a/src/_pytest/mark/structures.py +++ b/src/_pytest/mark/structures.py @@ -164,6 +164,14 @@ class Mark(object): #: keyword arguments of the mark decorator kwargs = attr.ib() # Dict[str, object] + #: source Mark for ids with parametrize Marks + _param_ids_from = attr.ib(default=None, repr=False) + #: resolved/generated ids with parametrize Marks + _param_ids_generated = attr.ib(default=None, repr=False) + + def _has_param_ids(self): + return "ids" in self.kwargs or len(self.args) >= 4 + def combined_with(self, other): """ :param other: the mark to combine with @@ -173,8 +181,20 @@ def combined_with(self, other): combines by appending args and merging the mappings """ assert self.name == other.name + + # Remember source of ids with parametrize Marks. + param_ids_from = None + if self.name == "parametrize": + if other._has_param_ids(): + param_ids_from = other + elif self._has_param_ids(): + param_ids_from = self + return Mark( - self.name, self.args + other.args, dict(self.kwargs, **other.kwargs) + self.name, + self.args + other.args, + dict(self.kwargs, **other.kwargs), + param_ids_from=param_ids_from, ) diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 5f1e6885be3..553bbf7104c 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -134,7 +134,7 @@ def pytest_generate_tests(metafunc): msg = "{0} has '{1}' mark, spelling should be 'parametrize'" fail(msg.format(metafunc.function.__name__, mark_name), pytrace=False) for marker in metafunc.definition.iter_markers(name="parametrize"): - metafunc.parametrize(*marker.args, **marker.kwargs) + metafunc.parametrize(*marker.args, _param_mark=marker, **marker.kwargs) def pytest_configure(config): @@ -955,7 +955,15 @@ def __init__(self, definition, fixtureinfo, config, cls=None, module=None): self._ids = set() self._arg2fixturedefs = fixtureinfo.name2fixturedefs - def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None): + def parametrize( + self, + argnames, + argvalues, + indirect=False, + ids=None, + scope=None, + _param_mark=None, + ): """ Add new invocations to the underlying test function using the list of argvalues for the given argnames. Parametrization is performed during the collection phase. If you need to setup expensive resources @@ -978,13 +986,22 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None) function so that it can perform more expensive setups during the setup phase of a test rather than at collection time. - :arg ids: list of string ids, or a callable. - If strings, each is corresponding to the argvalues so that they are - part of the test id. If None is given as id of specific test, the - automatically generated id for that argument will be used. - If callable, it should take one argument (a single argvalue) and return - a string or return None. If None, the automatically generated id for that - argument will be used. + :arg ids: sequence of (or generator for) ids for ``argvalues``, + or a callable to return part of the id for each argvalue. + + With sequences (and generators like ``itertools.count()``) the + returned ids should be of type ``string``, ``int``, ``float``, + ``bool``, or ``None``. + They are mapped to the corresponding index in ``argvalues``. + ``None`` means to use the auto-generated id. + + If it is a callable it will be called for each entry in + ``argvalues``, and the return value is used as part of the + auto-generated id for the whole set (where parts are joined with + dashes ("-")). + This is useful to provide more specific ids for certain items, e.g. + dates. Returning ``None`` will use an auto-generated id. + If no ids are provided they will be generated automatically from the argvalues. @@ -1012,8 +1029,18 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None) arg_values_types = self._resolve_arg_value_types(argnames, indirect) + # Use any already (possibly) generated ids with parametrize Marks. + if _param_mark and _param_mark._param_ids_from: + generated_ids = _param_mark._param_ids_from._param_ids_generated + if generated_ids is not None: + ids = generated_ids + ids = self._resolve_arg_ids(argnames, ids, parameters, item=self.definition) + # Store used (possibly generated) ids with parametrize Marks. + if _param_mark and _param_mark._param_ids_from and generated_ids is None: + object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids) + scopenum = scope2index( scope, descr="parametrize() call in {}".format(self.function.__name__) ) @@ -1048,26 +1075,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item): :rtype: List[str] :return: the list of ids for each argname given """ - from _pytest._io.saferepr import saferepr - idfn = None if callable(ids): idfn = ids ids = None if ids: func_name = self.function.__name__ - if len(ids) != len(parameters): - msg = "In {}: {} parameter sets specified, with different number of ids: {}" - fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False) - for id_value in ids: - if id_value is not None and not isinstance(id_value, six.string_types): - msg = "In {}: ids must be list of strings, found: {} (type: {!r})" + ids = self._validate_ids(ids, parameters, func_name) + ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item) + return ids + + def _validate_ids(self, ids, parameters, func_name): + try: + len(ids) + except TypeError: + try: + it = iter(ids) + except TypeError: + raise TypeError("ids must be a callable, sequence or generator") + else: + import itertools + + new_ids = list(itertools.islice(it, len(parameters))) + else: + new_ids = list(ids) + + if len(new_ids) != len(parameters): + msg = "In {}: {} parameter sets specified, with different number of ids: {}" + fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False) + for idx, id_value in enumerate(new_ids): + if id_value is not None: + if isinstance(id_value, (float, int, bool)): + new_ids[idx] = str(id_value) + elif not isinstance(id_value, six.string_types): + from _pytest._io.saferepr import saferepr + + msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}" fail( - msg.format(func_name, saferepr(id_value), type(id_value)), + msg.format(func_name, saferepr(id_value), type(id_value), idx), pytrace=False, ) - ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item) - return ids + return new_ids def _resolve_arg_value_types(self, argnames, indirect): """Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg" diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py index a0105fc747b..bf3b2948e23 100644 --- a/testing/python/metafunc.py +++ b/testing/python/metafunc.py @@ -8,6 +8,7 @@ import pytest from _pytest import fixtures from _pytest import python +from _pytest.outcomes import fail from _pytest.warnings import SHOW_PYTEST_WARNINGS_ARG PY3 = sys.version_info >= (3, 0) @@ -63,6 +64,39 @@ def func(x, y): pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6])) pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6])) + with pytest.raises( + TypeError, match="^ids must be a callable, sequence or generator$" + ): + metafunc.parametrize("y", [5, 6], ids=42) + + def test_parametrize_error_iterator(self): + def func(x): + raise NotImplementedError() + + class Exc(Exception): + def __repr__(self): + return "Exc(from_gen)" + + def gen(): + yield 0 + yield None + yield Exc() + + metafunc = self.Metafunc(func) + metafunc.parametrize("x", [1, 2], ids=gen()) + assert [(x.funcargs, x.id) for x in metafunc._calls] == [ + ({"x": 1}, "0"), + ({"x": 2}, "2"), + ] + with pytest.raises( + fail.Exception, + match=( + r"In func: ids must be list of string/float/int/bool, found:" + r" Exc\(from_gen\) \(type: \) at index 2" + ), + ): + metafunc.parametrize("x", [1, 2, 3], ids=gen()) + def test_parametrize_bad_scope(self, testdir): def func(x): pass @@ -156,6 +190,26 @@ def func(x, y): ("x", "y"), [("abc", "def"), ("ghi", "jkl")], ids=["one"] ) + def test_parametrize_ids_iterator_without_mark(self): + import itertools + + def func(x, y): + pass + + it = itertools.count() + + metafunc = self.Metafunc(func) + metafunc.parametrize("x", [1, 2], ids=it) + metafunc.parametrize("y", [3, 4], ids=it) + ids = [x.id for x in metafunc._calls] + assert ids == ["0-2", "0-3", "1-2", "1-3"] + + metafunc = self.Metafunc(func) + metafunc.parametrize("x", [1, 2], ids=it) + metafunc.parametrize("y", [3, 4], ids=it) + ids = [x.id for x in metafunc._calls] + assert ids == ["4-6", "4-7", "5-6", "5-7"] + def test_parametrize_empty_list(self): """#510""" @@ -512,9 +566,22 @@ def ids(d): @pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids) def test(arg): assert arg + + @pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids) + def test_int(arg): + assert arg """ ) - assert testdir.runpytest().ret == 0 + result = testdir.runpytest("-vv", "-s") + result.stdout.fnmatch_lines( + [ + "test_parametrize_ids_returns_non_string.py::test[arg0] PASSED", + "test_parametrize_ids_returns_non_string.py::test[arg1] PASSED", + "test_parametrize_ids_returns_non_string.py::test_int[1] PASSED", + "test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED", + "test_parametrize_ids_returns_non_string.py::test_int[True] PASSED", + ] + ) def test_idmaker_with_ids(self): from _pytest.python import idmaker @@ -1153,12 +1220,12 @@ def test_temp(temp): result.stdout.fnmatch_lines(["* 1 skipped *"]) def test_parametrized_ids_invalid_type(self, testdir): - """Tests parametrized with ids as non-strings (#1857).""" + """Test error with non-strings/non-ints, without generator (#1857).""" testdir.makepyfile( """ import pytest - @pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2)) + @pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type)) def test_ids_numbers(x,expected): assert x * 2 == expected """ @@ -1166,7 +1233,8 @@ def test_ids_numbers(x,expected): result = testdir.runpytest() result.stdout.fnmatch_lines( [ - "*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*" + "In test_ids_numbers: ids must be list of string/float/int/bool," + " found: <* 'type'> (type: <* 'type'>) at index 2" ] ) @@ -1764,3 +1832,39 @@ def test_foo(a): ) result = testdir.runpytest() result.assert_outcomes(passed=1) + + def test_parametrize_iterator(self, testdir): + testdir.makepyfile( + """ + import itertools + import pytest + + id_parametrize = pytest.mark.parametrize( + ids=("param%d" % i for i in itertools.count()) + ) + + @id_parametrize('y', ['a', 'b']) + def test1(y): + pass + + @id_parametrize('y', ['a', 'b']) + def test2(y): + pass + + @pytest.mark.parametrize("a, b", [(1, 2), (3, 4)], ids=itertools.count()) + def test_converted_to_str(a, b): + pass + """ + ) + result = testdir.runpytest("-vv", "-s") + result.stdout.fnmatch_lines( + [ + "test_parametrize_iterator.py::test1[param0] PASSED", + "test_parametrize_iterator.py::test1[param1] PASSED", + "test_parametrize_iterator.py::test2[param0] PASSED", + "test_parametrize_iterator.py::test2[param1] PASSED", + "test_parametrize_iterator.py::test_converted_to_str[0] PASSED", + "test_parametrize_iterator.py::test_converted_to_str[1] PASSED", + "*= 6 passed in *", + ] + )