Skip to content

Commit 93fd61b

Browse files
committed
parametrized: ids: support generator/iterator
Fixes #759 - Adjust test_parametrized_ids_invalid_type, create list to convert tuples Ref: #1857 (comment) - Changelog for int to str conversion Ref: #1857 (comment)
1 parent 1d368e0 commit 93fd61b

File tree

5 files changed

+199
-26
lines changed

5 files changed

+199
-26
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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import warnings
33
from collections import namedtuple
44
from collections.abc import MutableMapping
5+
from typing import List
6+
from typing import Optional
57
from typing import Set
68

79
import attr
@@ -144,7 +146,13 @@ class Mark:
144146
#: keyword arguments of the mark decorator
145147
kwargs = attr.ib() # Dict[str, object]
146148

147-
def combined_with(self, other):
149+
_param_ids_from = attr.ib(type=Optional["Mark"], default=None, repr=False)
150+
_param_ids_generated = attr.ib(type=Optional[List[str]], default=None, repr=False)
151+
152+
def _has_param_ids(self):
153+
return "ids" in self.kwargs or len(self.args) >= 4
154+
155+
def combined_with(self, other: "Mark") -> "Mark":
148156
"""
149157
:param other: the mark to combine with
150158
:type other: Mark
@@ -153,8 +161,19 @@ def combined_with(self, other):
153161
combines by appending args and merging the mappings
154162
"""
155163
assert self.name == other.name
164+
165+
param_ids_from = None # type: Optional[Mark]
166+
if self.name == "parametrize":
167+
if other._has_param_ids():
168+
param_ids_from = other
169+
elif self._has_param_ids():
170+
param_ids_from = self
171+
156172
return Mark(
157-
self.name, self.args + other.args, dict(self.kwargs, **other.kwargs)
173+
self.name,
174+
self.args + other.args,
175+
dict(self.kwargs, **other.kwargs),
176+
param_ids_from=param_ids_from,
158177
)
159178

160179

src/_pytest/python.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from functools import partial
1111
from textwrap import dedent
1212
from typing import List
13+
from typing import Optional
1314
from typing import Tuple
1415

1516
import py
@@ -36,6 +37,7 @@
3637
from _pytest.main import FSHookProxy
3738
from _pytest.mark import MARK_GEN
3839
from _pytest.mark.structures import get_unpacked_marks
40+
from _pytest.mark.structures import Mark
3941
from _pytest.mark.structures import normalize_mark_list
4042
from _pytest.outcomes import fail
4143
from _pytest.outcomes import skip
@@ -122,7 +124,7 @@ def pytest_cmdline_main(config):
122124

123125
def pytest_generate_tests(metafunc):
124126
for marker in metafunc.definition.iter_markers(name="parametrize"):
125-
metafunc.parametrize(*marker.args, **marker.kwargs)
127+
metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker)
126128

127129

128130
def pytest_configure(config):
@@ -915,7 +917,16 @@ def funcargnames(self):
915917
warnings.warn(FUNCARGNAMES, stacklevel=2)
916918
return self.fixturenames
917919

918-
def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None):
920+
def parametrize(
921+
self,
922+
argnames,
923+
argvalues,
924+
indirect=False,
925+
ids=None,
926+
scope=None,
927+
*,
928+
_param_mark: Optional[Mark] = None
929+
):
919930
""" Add new invocations to the underlying test function using the list
920931
of argvalues for the given argnames. Parametrization is performed
921932
during the collection phase. If you need to setup expensive resources
@@ -938,13 +949,21 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
938949
function so that it can perform more expensive setups during the
939950
setup phase of a test rather than at collection time.
940951
941-
:arg ids: list of string ids, or a callable.
942-
If strings, each is corresponding to the argvalues so that they are
943-
part of the test id. If None is given as id of specific test, the
944-
automatically generated id for that argument will be used.
945-
If callable, it should take one argument (a single argvalue) and return
946-
a string or return None. If None, the automatically generated id for that
947-
argument will be used.
952+
:arg ids: sequence of (or generator for) ids for ``argvalues``,
953+
or a callable to return part of the id for each argvalue.
954+
955+
With sequences (and generators like ``itertools.count()``) the
956+
returned ids should be of type ``string``, ``int``, ``float``,
957+
``bool``, or ``None``.
958+
They are mapped to the corresponding index in ``argvalues``.
959+
``None`` means to use the auto-generated id.
960+
961+
If it is a callable it will be called for each entry in
962+
``argvalues``, and the return value is used as part of the
963+
auto-generated id for the whole set.
964+
This is useful to provide more specific ids for certain items, e.g.
965+
dates. Returning ``None`` will use an auto-generated id.
966+
948967
If no ids are provided they will be generated automatically from
949968
the argvalues.
950969
@@ -978,8 +997,16 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
978997

979998
arg_values_types = self._resolve_arg_value_types(argnames, indirect)
980999

1000+
if _param_mark and _param_mark._param_ids_from:
1001+
generated_ids = _param_mark._param_ids_from._param_ids_generated
1002+
if generated_ids is not None:
1003+
ids = generated_ids
1004+
9811005
ids = self._resolve_arg_ids(argnames, ids, parameters, item=self.definition)
9821006

1007+
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
1008+
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
1009+
9831010
scopenum = scope2index(
9841011
scope, descr="parametrize() call in {}".format(self.function.__name__)
9851012
)
@@ -1014,26 +1041,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item):
10141041
:rtype: List[str]
10151042
:return: the list of ids for each argname given
10161043
"""
1017-
from _pytest._io.saferepr import saferepr
1018-
10191044
idfn = None
10201045
if callable(ids):
10211046
idfn = ids
10221047
ids = None
10231048
if ids:
10241049
func_name = self.function.__name__
1025-
if len(ids) != len(parameters):
1026-
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1027-
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1028-
for id_value in ids:
1029-
if id_value is not None and not isinstance(id_value, str):
1030-
msg = "In {}: ids must be list of strings, found: {} (type: {!r})"
1050+
ids = self._validate_ids(ids, parameters, func_name)
1051+
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1052+
return ids
1053+
1054+
def _validate_ids(self, ids, parameters, func_name):
1055+
try:
1056+
len(ids)
1057+
except TypeError:
1058+
try:
1059+
it = iter(ids)
1060+
except TypeError:
1061+
raise TypeError("ids must be a callable, sequence or generator")
1062+
else:
1063+
import itertools
1064+
1065+
new_ids = list(itertools.islice(it, len(parameters)))
1066+
else:
1067+
new_ids = list(ids)
1068+
1069+
if len(new_ids) != len(parameters):
1070+
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1071+
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1072+
for idx, id_value in enumerate(new_ids):
1073+
if id_value is not None:
1074+
if isinstance(id_value, (float, int, bool)):
1075+
new_ids[idx] = str(id_value)
1076+
elif not isinstance(id_value, str):
1077+
from _pytest._io.saferepr import saferepr
1078+
1079+
msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}"
10311080
fail(
1032-
msg.format(func_name, saferepr(id_value), type(id_value)),
1081+
msg.format(func_name, saferepr(id_value), type(id_value), idx),
10331082
pytrace=False,
10341083
)
1035-
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1036-
return ids
1084+
return new_ids
10371085

10381086
def _resolve_arg_value_types(self, argnames, indirect):
10391087
"""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
@@ -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
@@ -167,6 +201,26 @@ def func(x, y):
167201
("x", "y"), [("abc", "def"), ("ghi", "jkl")], ids=["one"]
168202
)
169203

204+
def test_parametrize_ids_iterator_without_mark(self):
205+
import itertools
206+
207+
def func(x, y):
208+
pass
209+
210+
it = itertools.count()
211+
212+
metafunc = self.Metafunc(func)
213+
metafunc.parametrize("x", [1, 2], ids=it)
214+
metafunc.parametrize("y", [3, 4], ids=it)
215+
ids = [x.id for x in metafunc._calls]
216+
assert ids == ["0-2", "0-3", "1-2", "1-3"]
217+
218+
metafunc = self.Metafunc(func)
219+
metafunc.parametrize("x", [1, 2], ids=it)
220+
metafunc.parametrize("y", [3, 4], ids=it)
221+
ids = [x.id for x in metafunc._calls]
222+
assert ids == ["4-6", "4-7", "5-6", "5-7"]
223+
170224
def test_parametrize_empty_list(self):
171225
"""#510"""
172226

@@ -534,9 +588,22 @@ def ids(d):
534588
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
535589
def test(arg):
536590
assert arg
591+
592+
@pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids)
593+
def test_int(arg):
594+
assert arg
537595
"""
538596
)
539-
assert testdir.runpytest().ret == 0
597+
result = testdir.runpytest("-vv", "-s")
598+
result.stdout.fnmatch_lines(
599+
[
600+
"test_parametrize_ids_returns_non_string.py::test[arg0] PASSED",
601+
"test_parametrize_ids_returns_non_string.py::test[arg1] PASSED",
602+
"test_parametrize_ids_returns_non_string.py::test_int[1] PASSED",
603+
"test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED",
604+
"test_parametrize_ids_returns_non_string.py::test_int[True] PASSED",
605+
]
606+
)
540607

541608
def test_idmaker_with_ids(self):
542609
from _pytest.python import idmaker
@@ -1186,20 +1253,21 @@ def test_temp(temp):
11861253
result.stdout.fnmatch_lines(["* 1 skipped *"])
11871254

11881255
def test_parametrized_ids_invalid_type(self, testdir):
1189-
"""Tests parametrized with ids as non-strings (#1857)."""
1256+
"""Test error with non-strings/non-ints, without generator (#1857)."""
11901257
testdir.makepyfile(
11911258
"""
11921259
import pytest
11931260
1194-
@pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2))
1261+
@pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type))
11951262
def test_ids_numbers(x,expected):
11961263
assert x * 2 == expected
11971264
"""
11981265
)
11991266
result = testdir.runpytest()
12001267
result.stdout.fnmatch_lines(
12011268
[
1202-
"*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*"
1269+
"In test_ids_numbers: ids must be list of string/float/int/bool,"
1270+
" found: <class 'type'> (type: <class 'type'>) at index 2"
12031271
]
12041272
)
12051273

@@ -1780,3 +1848,39 @@ def test_foo(a):
17801848
)
17811849
result = testdir.runpytest()
17821850
result.assert_outcomes(passed=1)
1851+
1852+
def test_parametrize_iterator(self, testdir):
1853+
testdir.makepyfile(
1854+
"""
1855+
import itertools
1856+
import pytest
1857+
1858+
id_parametrize = pytest.mark.parametrize(
1859+
ids=("param%d" % i for i in itertools.count())
1860+
)
1861+
1862+
@id_parametrize('y', ['a', 'b'])
1863+
def test1(y):
1864+
pass
1865+
1866+
@id_parametrize('y', ['a', 'b'])
1867+
def test2(y):
1868+
pass
1869+
1870+
@pytest.mark.parametrize("a, b", [(1, 2), (3, 4)], ids=itertools.count())
1871+
def test_converted_to_str(a, b):
1872+
pass
1873+
"""
1874+
)
1875+
result = testdir.runpytest("-vv", "-s")
1876+
result.stdout.fnmatch_lines(
1877+
[
1878+
"test_parametrize_iterator.py::test1[param0] PASSED",
1879+
"test_parametrize_iterator.py::test1[param1] PASSED",
1880+
"test_parametrize_iterator.py::test2[param0] PASSED",
1881+
"test_parametrize_iterator.py::test2[param1] PASSED",
1882+
"test_parametrize_iterator.py::test_converted_to_str[0] PASSED",
1883+
"test_parametrize_iterator.py::test_converted_to_str[1] PASSED",
1884+
"*= 6 passed in *",
1885+
]
1886+
)

0 commit comments

Comments
 (0)