Skip to content

Commit cb40f69

Browse files
authored
[4.6] parametrized: ids: support generator/iterator (#128)
Ref: 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) (cherry picked from commit 2c941b5) Conflicts: src/_pytest/mark/structures.py src/_pytest/python.py testing/python/metafunc.py
1 parent dfda553 commit cb40f69

File tree

5 files changed

+199
-25
lines changed

5 files changed

+199
-25
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/mark/structures.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,14 @@ class Mark(object):
164164
#: keyword arguments of the mark decorator
165165
kwargs = attr.ib() # Dict[str, object]
166166

167+
#: source Mark for ids with parametrize Marks
168+
_param_ids_from = attr.ib(default=None, repr=False)
169+
#: resolved/generated ids with parametrize Marks
170+
_param_ids_generated = attr.ib(default=None, repr=False)
171+
172+
def _has_param_ids(self):
173+
return "ids" in self.kwargs or len(self.args) >= 4
174+
167175
def combined_with(self, other):
168176
"""
169177
:param other: the mark to combine with
@@ -173,8 +181,20 @@ def combined_with(self, other):
173181
combines by appending args and merging the mappings
174182
"""
175183
assert self.name == other.name
184+
185+
# Remember source of ids with parametrize Marks.
186+
param_ids_from = None
187+
if self.name == "parametrize":
188+
if other._has_param_ids():
189+
param_ids_from = other
190+
elif self._has_param_ids():
191+
param_ids_from = self
192+
176193
return Mark(
177-
self.name, self.args + other.args, dict(self.kwargs, **other.kwargs)
194+
self.name,
195+
self.args + other.args,
196+
dict(self.kwargs, **other.kwargs),
197+
param_ids_from=param_ids_from,
178198
)
179199

180200

src/_pytest/python.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def pytest_generate_tests(metafunc):
134134
msg = "{0} has '{1}' mark, spelling should be 'parametrize'"
135135
fail(msg.format(metafunc.function.__name__, mark_name), pytrace=False)
136136
for marker in metafunc.definition.iter_markers(name="parametrize"):
137-
metafunc.parametrize(*marker.args, **marker.kwargs)
137+
metafunc.parametrize(*marker.args, _param_mark=marker, **marker.kwargs)
138138

139139

140140
def pytest_configure(config):
@@ -955,7 +955,15 @@ def __init__(self, definition, fixtureinfo, config, cls=None, module=None):
955955
self._ids = set()
956956
self._arg2fixturedefs = fixtureinfo.name2fixturedefs
957957

958-
def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None):
958+
def parametrize(
959+
self,
960+
argnames,
961+
argvalues,
962+
indirect=False,
963+
ids=None,
964+
scope=None,
965+
_param_mark=None,
966+
):
959967
""" Add new invocations to the underlying test function using the list
960968
of argvalues for the given argnames. Parametrization is performed
961969
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)
978986
function so that it can perform more expensive setups during the
979987
setup phase of a test rather than at collection time.
980988
981-
:arg ids: list of string ids, or a callable.
982-
If strings, each is corresponding to the argvalues so that they are
983-
part of the test id. If None is given as id of specific test, the
984-
automatically generated id for that argument will be used.
985-
If callable, it should take one argument (a single argvalue) and return
986-
a string or return None. If None, the automatically generated id for that
987-
argument will be used.
989+
:arg ids: sequence of (or generator for) ids for ``argvalues``,
990+
or a callable to return part of the id for each argvalue.
991+
992+
With sequences (and generators like ``itertools.count()``) the
993+
returned ids should be of type ``string``, ``int``, ``float``,
994+
``bool``, or ``None``.
995+
They are mapped to the corresponding index in ``argvalues``.
996+
``None`` means to use the auto-generated id.
997+
998+
If it is a callable it will be called for each entry in
999+
``argvalues``, and the return value is used as part of the
1000+
auto-generated id for the whole set (where parts are joined with
1001+
dashes ("-")).
1002+
This is useful to provide more specific ids for certain items, e.g.
1003+
dates. Returning ``None`` will use an auto-generated id.
1004+
9881005
If no ids are provided they will be generated automatically from
9891006
the argvalues.
9901007
@@ -1012,8 +1029,18 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
10121029

10131030
arg_values_types = self._resolve_arg_value_types(argnames, indirect)
10141031

1032+
# Use any already (possibly) generated ids with parametrize Marks.
1033+
if _param_mark and _param_mark._param_ids_from:
1034+
generated_ids = _param_mark._param_ids_from._param_ids_generated
1035+
if generated_ids is not None:
1036+
ids = generated_ids
1037+
10151038
ids = self._resolve_arg_ids(argnames, ids, parameters, item=self.definition)
10161039

1040+
# Store used (possibly generated) ids with parametrize Marks.
1041+
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
1042+
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
1043+
10171044
scopenum = scope2index(
10181045
scope, descr="parametrize() call in {}".format(self.function.__name__)
10191046
)
@@ -1048,26 +1075,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item):
10481075
:rtype: List[str]
10491076
:return: the list of ids for each argname given
10501077
"""
1051-
from _pytest._io.saferepr import saferepr
1052-
10531078
idfn = None
10541079
if callable(ids):
10551080
idfn = ids
10561081
ids = None
10571082
if ids:
10581083
func_name = self.function.__name__
1059-
if len(ids) != len(parameters):
1060-
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1061-
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1062-
for id_value in ids:
1063-
if id_value is not None and not isinstance(id_value, six.string_types):
1064-
msg = "In {}: ids must be list of strings, found: {} (type: {!r})"
1084+
ids = self._validate_ids(ids, parameters, func_name)
1085+
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1086+
return ids
1087+
1088+
def _validate_ids(self, ids, parameters, func_name):
1089+
try:
1090+
len(ids)
1091+
except TypeError:
1092+
try:
1093+
it = iter(ids)
1094+
except TypeError:
1095+
raise TypeError("ids must be a callable, sequence or generator")
1096+
else:
1097+
import itertools
1098+
1099+
new_ids = list(itertools.islice(it, len(parameters)))
1100+
else:
1101+
new_ids = list(ids)
1102+
1103+
if len(new_ids) != len(parameters):
1104+
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1105+
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1106+
for idx, id_value in enumerate(new_ids):
1107+
if id_value is not None:
1108+
if isinstance(id_value, (float, int, bool)):
1109+
new_ids[idx] = str(id_value)
1110+
elif not isinstance(id_value, six.string_types):
1111+
from _pytest._io.saferepr import saferepr
1112+
1113+
msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}"
10651114
fail(
1066-
msg.format(func_name, saferepr(id_value), type(id_value)),
1115+
msg.format(func_name, saferepr(id_value), type(id_value), idx),
10671116
pytrace=False,
10681117
)
1069-
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1070-
return ids
1118+
return new_ids
10711119

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

testing/python/metafunc.py

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
from _pytest import fixtures
1010
from _pytest import python
11+
from _pytest.outcomes import fail
1112
from _pytest.warnings import SHOW_PYTEST_WARNINGS_ARG
1213

1314
PY3 = sys.version_info >= (3, 0)
@@ -63,6 +64,39 @@ def func(x, y):
6364
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
6465
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
6566

67+
with pytest.raises(
68+
TypeError, match="^ids must be a callable, sequence or generator$"
69+
):
70+
metafunc.parametrize("y", [5, 6], ids=42)
71+
72+
def test_parametrize_error_iterator(self):
73+
def func(x):
74+
raise NotImplementedError()
75+
76+
class Exc(Exception):
77+
def __repr__(self):
78+
return "Exc(from_gen)"
79+
80+
def gen():
81+
yield 0
82+
yield None
83+
yield Exc()
84+
85+
metafunc = self.Metafunc(func)
86+
metafunc.parametrize("x", [1, 2], ids=gen())
87+
assert [(x.funcargs, x.id) for x in metafunc._calls] == [
88+
({"x": 1}, "0"),
89+
({"x": 2}, "2"),
90+
]
91+
with pytest.raises(
92+
fail.Exception,
93+
match=(
94+
r"In func: ids must be list of string/float/int/bool, found:"
95+
r" Exc\(from_gen\) \(type: <class .*Exc'>\) at index 2"
96+
),
97+
):
98+
metafunc.parametrize("x", [1, 2, 3], ids=gen())
99+
66100
def test_parametrize_bad_scope(self, testdir):
67101
def func(x):
68102
pass
@@ -156,6 +190,26 @@ def func(x, y):
156190
("x", "y"), [("abc", "def"), ("ghi", "jkl")], ids=["one"]
157191
)
158192

193+
def test_parametrize_ids_iterator_without_mark(self):
194+
import itertools
195+
196+
def func(x, y):
197+
pass
198+
199+
it = itertools.count()
200+
201+
metafunc = self.Metafunc(func)
202+
metafunc.parametrize("x", [1, 2], ids=it)
203+
metafunc.parametrize("y", [3, 4], ids=it)
204+
ids = [x.id for x in metafunc._calls]
205+
assert ids == ["0-2", "0-3", "1-2", "1-3"]
206+
207+
metafunc = self.Metafunc(func)
208+
metafunc.parametrize("x", [1, 2], ids=it)
209+
metafunc.parametrize("y", [3, 4], ids=it)
210+
ids = [x.id for x in metafunc._calls]
211+
assert ids == ["4-6", "4-7", "5-6", "5-7"]
212+
159213
def test_parametrize_empty_list(self):
160214
"""#510"""
161215

@@ -512,9 +566,22 @@ def ids(d):
512566
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
513567
def test(arg):
514568
assert arg
569+
570+
@pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids)
571+
def test_int(arg):
572+
assert arg
515573
"""
516574
)
517-
assert testdir.runpytest().ret == 0
575+
result = testdir.runpytest("-vv", "-s")
576+
result.stdout.fnmatch_lines(
577+
[
578+
"test_parametrize_ids_returns_non_string.py::test[arg0] PASSED",
579+
"test_parametrize_ids_returns_non_string.py::test[arg1] PASSED",
580+
"test_parametrize_ids_returns_non_string.py::test_int[1] PASSED",
581+
"test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED",
582+
"test_parametrize_ids_returns_non_string.py::test_int[True] PASSED",
583+
]
584+
)
518585

519586
def test_idmaker_with_ids(self):
520587
from _pytest.python import idmaker
@@ -1153,20 +1220,21 @@ def test_temp(temp):
11531220
result.stdout.fnmatch_lines(["* 1 skipped *"])
11541221

11551222
def test_parametrized_ids_invalid_type(self, testdir):
1156-
"""Tests parametrized with ids as non-strings (#1857)."""
1223+
"""Test error with non-strings/non-ints, without generator (#1857)."""
11571224
testdir.makepyfile(
11581225
"""
11591226
import pytest
11601227
1161-
@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))
11621229
def test_ids_numbers(x,expected):
11631230
assert x * 2 == expected
11641231
"""
11651232
)
11661233
result = testdir.runpytest()
11671234
result.stdout.fnmatch_lines(
11681235
[
1169-
"*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: <* 'type'> (type: <* 'type'>) at index 2"
11701238
]
11711239
)
11721240

@@ -1764,3 +1832,39 @@ def test_foo(a):
17641832
)
17651833
result = testdir.runpytest()
17661834
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(
1861+
[
1862+
"test_parametrize_iterator.py::test1[param0] PASSED",
1863+
"test_parametrize_iterator.py::test1[param1] PASSED",
1864+
"test_parametrize_iterator.py::test2[param0] PASSED",
1865+
"test_parametrize_iterator.py::test2[param1] 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)