Skip to content

Commit b0d3ba6

Browse files
committed
[RFC] parametrized: ids: support generator/iterator
Fixes pytest-dev#759 convert, tests 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) coverage _validate_ids: convert int/float/bool, doc revisit
1 parent e2022a6 commit b0d3ba6

File tree

4 files changed

+137
-22
lines changed

4 files changed

+137
-22
lines changed

changelog/1857.improvement.rst

Lines changed: 1 addition & 0 deletions
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

Lines changed: 1 addition & 0 deletions
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

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -939,13 +939,21 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
939939
function so that it can perform more expensive setups during the
940940
setup phase of a test rather than at collection time.
941941
942-
:arg ids: list of string ids, or a callable.
943-
If strings, each is corresponding to the argvalues so that they are
944-
part of the test id. If None is given as id of specific test, the
945-
automatically generated id for that argument will be used.
946-
If callable, it should take one argument (a single argvalue) and return
947-
a string or return None. If None, the automatically generated id for that
948-
argument will be used.
942+
:arg ids: sequence of (or generator for) ids for ``argvalues``,
943+
or a callable to return part of the id for each argvalue.
944+
945+
With sequences (and generators like ``itertools.count()``) the
946+
returned ids should be of type ``string``, ``int``, ``float``,
947+
``bool``, or ``None``.
948+
They are mapped to the corresponding index in ``argvalues``.
949+
``None`` means to use the auto-generated id.
950+
951+
If it is a callable it will be called for each entry in
952+
``argvalues``, and the return value is used as part of the
953+
auto-generated id for the whole set.
954+
This is useful to provide more specific ids for certain times, e.g.
955+
dates. Returning ``None`` will use an auto-generated id.
956+
949957
If no ids are provided they will be generated automatically from
950958
the argvalues.
951959
@@ -1009,26 +1017,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item):
10091017
:rtype: List[str]
10101018
:return: the list of ids for each argname given
10111019
"""
1012-
from _pytest._io.saferepr import saferepr
1013-
10141020
idfn = None
10151021
if callable(ids):
10161022
idfn = ids
10171023
ids = None
10181024
if ids:
10191025
func_name = self.function.__name__
1020-
if len(ids) != len(parameters):
1021-
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1022-
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1023-
for id_value in ids:
1024-
if id_value is not None and not isinstance(id_value, str):
1025-
msg = "In {}: ids must be list of strings, found: {} (type: {!r})"
1026+
ids = self._validate_ids(ids, parameters, func_name)
1027+
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1028+
return ids
1029+
1030+
def _validate_ids(self, ids, parameters, func_name):
1031+
try:
1032+
len(ids)
1033+
except TypeError:
1034+
try:
1035+
it = iter(ids)
1036+
except TypeError:
1037+
raise TypeError("ids must be a callable, sequence or generator")
1038+
else:
1039+
import itertools
1040+
1041+
new_ids = list(itertools.islice(it, len(parameters)))
1042+
else:
1043+
new_ids = list(ids)
1044+
1045+
if len(new_ids) != len(parameters):
1046+
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1047+
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1048+
for idx, id_value in enumerate(new_ids):
1049+
if id_value is not None:
1050+
if isinstance(id_value, (float, int, bool)):
1051+
new_ids[idx] = str(id_value)
1052+
elif not isinstance(id_value, str):
1053+
from _pytest._io.saferepr import saferepr
1054+
1055+
msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}"
10261056
fail(
1027-
msg.format(func_name, saferepr(id_value), type(id_value)),
1057+
msg.format(func_name, saferepr(id_value), type(id_value), idx),
10281058
pytrace=False,
10291059
)
1030-
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1031-
return ids
1060+
return new_ids
10321061

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

testing/python/metafunc.py

Lines changed: 88 additions & 4 deletions
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
@@ -521,9 +555,22 @@ def ids(d):
521555
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
522556
def test(arg):
523557
assert arg
558+
559+
@pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids)
560+
def test_int(arg):
561+
assert arg
524562
"""
525563
)
526-
assert testdir.runpytest().ret == 0
564+
result = testdir.runpytest("-vv", "-s")
565+
result.stdout.fnmatch_lines(
566+
[
567+
"test_parametrize_ids_returns_non_string.py::test[arg0] PASSED",
568+
"test_parametrize_ids_returns_non_string.py::test[arg1] PASSED",
569+
"test_parametrize_ids_returns_non_string.py::test_int[1] PASSED",
570+
"test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED",
571+
"test_parametrize_ids_returns_non_string.py::test_int[True] PASSED",
572+
]
573+
)
527574

528575
def test_idmaker_with_ids(self):
529576
from _pytest.python import idmaker
@@ -1173,20 +1220,21 @@ def test_temp(temp):
11731220
result.stdout.fnmatch_lines(["* 1 skipped *"])
11741221

11751222
def test_parametrized_ids_invalid_type(self, testdir):
1176-
"""Tests parametrized with ids as non-strings (#1857)."""
1223+
"""Test error with non-strings/non-ints, without generator (#1857)."""
11771224
testdir.makepyfile(
11781225
"""
11791226
import pytest
11801227
1181-
@pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2))
1228+
@pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type))
11821229
def test_ids_numbers(x,expected):
11831230
assert x * 2 == expected
11841231
"""
11851232
)
11861233
result = testdir.runpytest()
11871234
result.stdout.fnmatch_lines(
11881235
[
1189-
"*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*"
1236+
"In test_ids_numbers: ids must be list of string/float/int/bool,"
1237+
" found: <class 'type'> (type: <class 'type'>) at index 2"
11901238
]
11911239
)
11921240

@@ -1784,3 +1832,39 @@ def test_foo(a):
17841832
)
17851833
result = testdir.runpytest()
17861834
result.assert_outcomes(passed=1)
1835+
1836+
def test_parametrize_iterator(self, testdir):
1837+
testdir.makepyfile(
1838+
"""
1839+
import itertools
1840+
import pytest
1841+
1842+
id_parametrize = pytest.mark.parametrize(
1843+
ids=("param%d" % i for i in itertools.count()
1844+
))
1845+
1846+
@id_parametrize('y', ['a', 'b'])
1847+
def test1(y):
1848+
pass
1849+
1850+
@id_parametrize('y', ['a', 'b'])
1851+
def test2(y):
1852+
pass
1853+
1854+
@pytest.mark.parametrize("a, b", [(1, 2), (3, 4)], ids=itertools.count())
1855+
def test_converted_to_str(a, b):
1856+
pass
1857+
"""
1858+
)
1859+
result = testdir.runpytest("-vv", "-s")
1860+
result.stdout.fnmatch_lines_random( # random for py35.
1861+
[
1862+
"test_parametrize_iterator.py::test1[param0] PASSED",
1863+
"test_parametrize_iterator.py::test1[param1] PASSED",
1864+
"test_parametrize_iterator.py::test2[param2] PASSED",
1865+
"test_parametrize_iterator.py::test2[param3] PASSED",
1866+
"test_parametrize_iterator.py::test_converted_to_str[0] PASSED",
1867+
"test_parametrize_iterator.py::test_converted_to_str[1] PASSED",
1868+
"*= 6 passed in *",
1869+
]
1870+
)

0 commit comments

Comments
 (0)