Skip to content

Commit 0da413f

Browse files
committed
parametrized: ids: support generator/iterator
Fixes pytest-dev#759 - Adjust test_parametrized_ids_invalid_type, create list to convert tuples Ref: pytest-dev#1857 (comment) - Changelog for int to str conversion Ref: pytest-dev#1857 (comment)
1 parent 89eeefb commit 0da413f

File tree

4 files changed

+137
-22
lines changed

4 files changed

+137
-22
lines changed

changelog/1857.improvement.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``pytest.mark.parametrize`` accepts integers for ``ids`` again, converting it to strings.

changelog/759.improvement.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
``pytest.mark.parametrize`` supports iterators and generators for ``ids``.

src/_pytest/python.py

+47-18
Original file line numberDiff line numberDiff line change
@@ -952,13 +952,21 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
952952
function so that it can perform more expensive setups during the
953953
setup phase of a test rather than at collection time.
954954
955-
:arg ids: list of string ids, or a callable.
956-
If strings, each is corresponding to the argvalues so that they are
957-
part of the test id. If None is given as id of specific test, the
958-
automatically generated id for that argument will be used.
959-
If callable, it should take one argument (a single argvalue) and return
960-
a string or return None. If None, the automatically generated id for that
961-
argument will be used.
955+
:arg ids: sequence of (or generator for) ids for ``argvalues``,
956+
or a callable to return part of the id for each argvalue.
957+
958+
With sequences (and generators like ``itertools.count()``) the
959+
returned ids should be of type ``string``, ``int``, ``float``,
960+
``bool``, or ``None``.
961+
They are mapped to the corresponding index in ``argvalues``.
962+
``None`` means to use the auto-generated id.
963+
964+
If it is a callable it will be called for each entry in
965+
``argvalues``, and the return value is used as part of the
966+
auto-generated id for the whole set.
967+
This is useful to provide more specific ids for certain items, e.g.
968+
dates. Returning ``None`` will use an auto-generated id.
969+
962970
If no ids are provided they will be generated automatically from
963971
the argvalues.
964972
@@ -1028,26 +1036,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item):
10281036
:rtype: List[str]
10291037
:return: the list of ids for each argname given
10301038
"""
1031-
from _pytest._io.saferepr import saferepr
1032-
10331039
idfn = None
10341040
if callable(ids):
10351041
idfn = ids
10361042
ids = None
10371043
if ids:
10381044
func_name = self.function.__name__
1039-
if len(ids) != len(parameters):
1040-
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1041-
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1042-
for id_value in ids:
1043-
if id_value is not None and not isinstance(id_value, str):
1044-
msg = "In {}: ids must be list of strings, found: {} (type: {!r})"
1045+
ids = self._validate_ids(ids, parameters, func_name)
1046+
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1047+
return ids
1048+
1049+
def _validate_ids(self, ids, parameters, func_name):
1050+
try:
1051+
len(ids)
1052+
except TypeError:
1053+
try:
1054+
it = iter(ids)
1055+
except TypeError:
1056+
raise TypeError("ids must be a callable, sequence or generator")
1057+
else:
1058+
import itertools
1059+
1060+
new_ids = list(itertools.islice(it, len(parameters)))
1061+
else:
1062+
new_ids = list(ids)
1063+
1064+
if len(new_ids) != len(parameters):
1065+
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1066+
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1067+
for idx, id_value in enumerate(new_ids):
1068+
if id_value is not None:
1069+
if isinstance(id_value, (float, int, bool)):
1070+
new_ids[idx] = str(id_value)
1071+
elif not isinstance(id_value, str):
1072+
from _pytest._io.saferepr import saferepr
1073+
1074+
msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}"
10451075
fail(
1046-
msg.format(func_name, saferepr(id_value), type(id_value)),
1076+
msg.format(func_name, saferepr(id_value), type(id_value), idx),
10471077
pytrace=False,
10481078
)
1049-
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1050-
return ids
1079+
return new_ids
10511080

10521081
def _resolve_arg_value_types(self, argnames, indirect):
10531082
"""Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg"

testing/python/metafunc.py

+88-4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010
from _pytest import fixtures
1111
from _pytest import python
12+
from _pytest.outcomes import fail
1213

1314

1415
class TestMetafunc:
@@ -61,6 +62,39 @@ def func(x, y):
6162
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
6263
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
6364

65+
with pytest.raises(
66+
TypeError, match="^ids must be a callable, sequence or generator$"
67+
):
68+
metafunc.parametrize("y", [5, 6], ids=42)
69+
70+
def test_parametrize_error_iterator(self):
71+
def func(x):
72+
raise NotImplementedError()
73+
74+
class Exc(Exception):
75+
def __repr__(self):
76+
return "Exc(from_gen)"
77+
78+
def gen():
79+
yield 0
80+
yield None
81+
yield Exc()
82+
83+
metafunc = self.Metafunc(func)
84+
metafunc.parametrize("x", [1, 2], ids=gen())
85+
assert [(x.funcargs, x.id) for x in metafunc._calls] == [
86+
({"x": 1}, "0"),
87+
({"x": 2}, "2"),
88+
]
89+
with pytest.raises(
90+
fail.Exception,
91+
match=(
92+
r"In func: ids must be list of string/float/int/bool, found:"
93+
r" Exc\(from_gen\) \(type: <class .*Exc'>\) at index 2"
94+
),
95+
):
96+
metafunc.parametrize("x", [1, 2, 3], ids=gen())
97+
6498
def test_parametrize_bad_scope(self, testdir):
6599
def func(x):
66100
pass
@@ -534,9 +568,22 @@ def ids(d):
534568
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
535569
def test(arg):
536570
assert arg
571+
572+
@pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids)
573+
def test_int(arg):
574+
assert arg
537575
"""
538576
)
539-
assert testdir.runpytest().ret == 0
577+
result = testdir.runpytest("-vv", "-s")
578+
result.stdout.fnmatch_lines(
579+
[
580+
"test_parametrize_ids_returns_non_string.py::test[arg0] PASSED",
581+
"test_parametrize_ids_returns_non_string.py::test[arg1] PASSED",
582+
"test_parametrize_ids_returns_non_string.py::test_int[1] PASSED",
583+
"test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED",
584+
"test_parametrize_ids_returns_non_string.py::test_int[True] PASSED",
585+
]
586+
)
540587

541588
def test_idmaker_with_ids(self):
542589
from _pytest.python import idmaker
@@ -1186,20 +1233,21 @@ def test_temp(temp):
11861233
result.stdout.fnmatch_lines(["* 1 skipped *"])
11871234

11881235
def test_parametrized_ids_invalid_type(self, testdir):
1189-
"""Tests parametrized with ids as non-strings (#1857)."""
1236+
"""Test error with non-strings/non-ints, without generator (#1857)."""
11901237
testdir.makepyfile(
11911238
"""
11921239
import pytest
11931240
1194-
@pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2))
1241+
@pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type))
11951242
def test_ids_numbers(x,expected):
11961243
assert x * 2 == expected
11971244
"""
11981245
)
11991246
result = testdir.runpytest()
12001247
result.stdout.fnmatch_lines(
12011248
[
1202-
"*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*"
1249+
"In test_ids_numbers: ids must be list of string/float/int/bool,"
1250+
" found: <class 'type'> (type: <class 'type'>) at index 2"
12031251
]
12041252
)
12051253

@@ -1776,3 +1824,39 @@ def test_foo(a):
17761824
)
17771825
result = testdir.runpytest()
17781826
result.assert_outcomes(passed=1)
1827+
1828+
def test_parametrize_iterator(self, testdir):
1829+
testdir.makepyfile(
1830+
"""
1831+
import itertools
1832+
import pytest
1833+
1834+
id_parametrize = pytest.mark.parametrize(
1835+
ids=("param%d" % i for i in itertools.count())
1836+
)
1837+
1838+
@id_parametrize('y', ['a', 'b'])
1839+
def test1(y):
1840+
pass
1841+
1842+
@id_parametrize('y', ['a', 'b'])
1843+
def test2(y):
1844+
pass
1845+
1846+
@pytest.mark.parametrize("a, b", [(1, 2), (3, 4)], ids=itertools.count())
1847+
def test_converted_to_str(a, b):
1848+
pass
1849+
"""
1850+
)
1851+
result = testdir.runpytest("-vv", "-s")
1852+
result.stdout.fnmatch_lines_random( # random for py35.
1853+
[
1854+
"test_parametrize_iterator.py::test1[param0] PASSED",
1855+
"test_parametrize_iterator.py::test1[param1] PASSED",
1856+
"test_parametrize_iterator.py::test2[param2] PASSED",
1857+
"test_parametrize_iterator.py::test2[param3] PASSED",
1858+
"test_parametrize_iterator.py::test_converted_to_str[0] PASSED",
1859+
"test_parametrize_iterator.py::test_converted_to_str[1] PASSED",
1860+
"*= 6 passed in *",
1861+
]
1862+
)

0 commit comments

Comments
 (0)