Skip to content

Commit 2c941b5

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 98c899c commit 2c941b5

File tree

5 files changed

+205
-26
lines changed

5 files changed

+205
-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: 24 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,15 @@ class Mark:
144146
#: keyword arguments of the mark decorator
145147
kwargs = attr.ib() # Dict[str, object]
146148

147-
def combined_with(self, other):
149+
#: source Mark for ids with parametrize Marks
150+
_param_ids_from = attr.ib(type=Optional["Mark"], default=None, repr=False)
151+
#: resolved/generated ids with parametrize Marks
152+
_param_ids_generated = attr.ib(type=Optional[List[str]], default=None, repr=False)
153+
154+
def _has_param_ids(self):
155+
return "ids" in self.kwargs or len(self.args) >= 4
156+
157+
def combined_with(self, other: "Mark") -> "Mark":
148158
"""
149159
:param other: the mark to combine with
150160
:type other: Mark
@@ -153,8 +163,20 @@ def combined_with(self, other):
153163
combines by appending args and merging the mappings
154164
"""
155165
assert self.name == other.name
166+
167+
# Remember source of ids with parametrize Marks.
168+
param_ids_from = None # type: Optional[Mark]
169+
if self.name == "parametrize":
170+
if other._has_param_ids():
171+
param_ids_from = other
172+
elif self._has_param_ids():
173+
param_ids_from = self
174+
156175
return Mark(
157-
self.name, self.args + other.args, dict(self.kwargs, **other.kwargs)
176+
self.name,
177+
self.args + other.args,
178+
dict(self.kwargs, **other.kwargs),
179+
param_ids_from=param_ids_from,
158180
)
159181

160182

src/_pytest/python.py

Lines changed: 71 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):
@@ -914,7 +916,16 @@ def funcargnames(self):
914916
warnings.warn(FUNCARGNAMES, stacklevel=2)
915917
return self.fixturenames
916918

917-
def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None):
919+
def parametrize(
920+
self,
921+
argnames,
922+
argvalues,
923+
indirect=False,
924+
ids=None,
925+
scope=None,
926+
*,
927+
_param_mark: Optional[Mark] = None
928+
):
918929
""" Add new invocations to the underlying test function using the list
919930
of argvalues for the given argnames. Parametrization is performed
920931
during the collection phase. If you need to setup expensive resources
@@ -937,13 +948,22 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
937948
function so that it can perform more expensive setups during the
938949
setup phase of a test rather than at collection time.
939950
940-
:arg ids: list of string ids, or a callable.
941-
If strings, each is corresponding to the argvalues so that they are
942-
part of the test id. If None is given as id of specific test, the
943-
automatically generated id for that argument will be used.
944-
If callable, it should take one argument (a single argvalue) and return
945-
a string or return None. If None, the automatically generated id for that
946-
argument will be used.
951+
:arg ids: sequence of (or generator for) ids for ``argvalues``,
952+
or a callable to return part of the id for each argvalue.
953+
954+
With sequences (and generators like ``itertools.count()``) the
955+
returned ids should be of type ``string``, ``int``, ``float``,
956+
``bool``, or ``None``.
957+
They are mapped to the corresponding index in ``argvalues``.
958+
``None`` means to use the auto-generated id.
959+
960+
If it is a callable it will be called for each entry in
961+
``argvalues``, and the return value is used as part of the
962+
auto-generated id for the whole set (where parts are joined with
963+
dashes ("-")).
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+
947967
If no ids are provided they will be generated automatically from
948968
the argvalues.
949969
@@ -977,8 +997,18 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
977997

978998
arg_values_types = self._resolve_arg_value_types(argnames, indirect)
979999

1000+
# Use any already (possibly) generated ids with parametrize Marks.
1001+
if _param_mark and _param_mark._param_ids_from:
1002+
generated_ids = _param_mark._param_ids_from._param_ids_generated
1003+
if generated_ids is not None:
1004+
ids = generated_ids
1005+
9801006
ids = self._resolve_arg_ids(argnames, ids, parameters, item=self.definition)
9811007

1008+
# Store used (possibly generated) ids with parametrize Marks.
1009+
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
1010+
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
1011+
9821012
scopenum = scope2index(
9831013
scope, descr="parametrize() call in {}".format(self.function.__name__)
9841014
)
@@ -1013,26 +1043,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item):
10131043
:rtype: List[str]
10141044
:return: the list of ids for each argname given
10151045
"""
1016-
from _pytest._io.saferepr import saferepr
1017-
10181046
idfn = None
10191047
if callable(ids):
10201048
idfn = ids
10211049
ids = None
10221050
if ids:
10231051
func_name = self.function.__name__
1024-
if len(ids) != len(parameters):
1025-
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1026-
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1027-
for id_value in ids:
1028-
if id_value is not None and not isinstance(id_value, str):
1029-
msg = "In {}: ids must be list of strings, found: {} (type: {!r})"
1052+
ids = self._validate_ids(ids, parameters, func_name)
1053+
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1054+
return ids
1055+
1056+
def _validate_ids(self, ids, parameters, func_name):
1057+
try:
1058+
len(ids)
1059+
except TypeError:
1060+
try:
1061+
it = iter(ids)
1062+
except TypeError:
1063+
raise TypeError("ids must be a callable, sequence or generator")
1064+
else:
1065+
import itertools
1066+
1067+
new_ids = list(itertools.islice(it, len(parameters)))
1068+
else:
1069+
new_ids = list(ids)
1070+
1071+
if len(new_ids) != len(parameters):
1072+
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1073+
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
1074+
for idx, id_value in enumerate(new_ids):
1075+
if id_value is not None:
1076+
if isinstance(id_value, (float, int, bool)):
1077+
new_ids[idx] = str(id_value)
1078+
elif not isinstance(id_value, str):
1079+
from _pytest._io.saferepr import saferepr
1080+
1081+
msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}"
10301082
fail(
1031-
msg.format(func_name, saferepr(id_value), type(id_value)),
1083+
msg.format(func_name, saferepr(id_value), type(id_value), idx),
10321084
pytrace=False,
10331085
)
1034-
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
1035-
return ids
1086+
return new_ids
10361087

10371088
def _resolve_arg_value_types(self, argnames, indirect):
10381089
"""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
from _pytest.python import _idval
1314

1415

@@ -62,6 +63,39 @@ def func(x, y):
6263
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
6364
pytest.raises(ValueError, lambda: metafunc.parametrize("y", [5, 6]))
6465

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

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

@@ -527,9 +581,22 @@ def ids(d):
527581
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
528582
def test(arg):
529583
assert arg
584+
585+
@pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids)
586+
def test_int(arg):
587+
assert arg
530588
"""
531589
)
532-
assert testdir.runpytest().ret == 0
590+
result = testdir.runpytest("-vv", "-s")
591+
result.stdout.fnmatch_lines(
592+
[
593+
"test_parametrize_ids_returns_non_string.py::test[arg0] PASSED",
594+
"test_parametrize_ids_returns_non_string.py::test[arg1] PASSED",
595+
"test_parametrize_ids_returns_non_string.py::test_int[1] PASSED",
596+
"test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED",
597+
"test_parametrize_ids_returns_non_string.py::test_int[True] PASSED",
598+
]
599+
)
533600

534601
def test_idmaker_with_ids(self):
535602
from _pytest.python import idmaker
@@ -1179,20 +1246,21 @@ def test_temp(temp):
11791246
result.stdout.fnmatch_lines(["* 1 skipped *"])
11801247

11811248
def test_parametrized_ids_invalid_type(self, testdir):
1182-
"""Tests parametrized with ids as non-strings (#1857)."""
1249+
"""Test error with non-strings/non-ints, without generator (#1857)."""
11831250
testdir.makepyfile(
11841251
"""
11851252
import pytest
11861253
1187-
@pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2))
1254+
@pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type))
11881255
def test_ids_numbers(x,expected):
11891256
assert x * 2 == expected
11901257
"""
11911258
)
11921259
result = testdir.runpytest()
11931260
result.stdout.fnmatch_lines(
11941261
[
1195-
"*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*"
1262+
"In test_ids_numbers: ids must be list of string/float/int/bool,"
1263+
" found: <class 'type'> (type: <class 'type'>) at index 2"
11961264
]
11971265
)
11981266

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

0 commit comments

Comments
 (0)