Skip to content

Commit

Permalink
[RFC] parametrized: ids: support generator/iterator
Browse files Browse the repository at this point in the history
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
  • Loading branch information
blueyed committed Nov 12, 2019
1 parent e2022a6 commit 375d06c
Show file tree
Hide file tree
Showing 4 changed files with 137 additions and 22 deletions.
1 change: 1 addition & 0 deletions changelog/1857.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``pytest.mark.parametrize`` accepts integers for ``ids`` again, converting it to strings.
1 change: 1 addition & 0 deletions changelog/759.improvement.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``pytest.mark.parametrize`` supports iterators and generators for ``ids``.
65 changes: 47 additions & 18 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -939,13 +939,21 @@ def parametrize(self, argnames, argvalues, indirect=False, ids=None, scope=None)
function so that it can perform more expensive setups during the
setup phase of a test rather than at collection time.
:arg ids: list of string ids, or a callable.
If strings, each is corresponding to the argvalues so that they are
part of the test id. If None is given as id of specific test, the
automatically generated id for that argument will be used.
If callable, it should take one argument (a single argvalue) and return
a string or return None. If None, the automatically generated id for that
argument will be used.
:arg ids: sequence of (or generator for) ids for ``argvalues``,
or a callable to return part of the id for each argvalue.
With sequences (and generators like ``itertools.count()``) the
returned ids should be of type ``string``, ``int``, ``float``,
``bool``, or ``None``.
They are mapped to the corresponding index in ``argvalues``.
``None`` means to use the auto-generated id.
If it is a callable it will be called for each entry in
``argvalues``, and the return value is used as part of the
auto-generated id for the whole set.
This is useful to provide more specific ids for certain times, e.g.
dates. Returning ``None`` will use an auto-generated id.
If no ids are provided they will be generated automatically from
the argvalues.
Expand Down Expand Up @@ -1009,26 +1017,47 @@ def _resolve_arg_ids(self, argnames, ids, parameters, item):
:rtype: List[str]
:return: the list of ids for each argname given
"""
from _pytest._io.saferepr import saferepr

idfn = None
if callable(ids):
idfn = ids
ids = None
if ids:
func_name = self.function.__name__
if len(ids) != len(parameters):
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
for id_value in ids:
if id_value is not None and not isinstance(id_value, str):
msg = "In {}: ids must be list of strings, found: {} (type: {!r})"
ids = self._validate_ids(ids, parameters, func_name)
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
return ids

def _validate_ids(self, ids, parameters, func_name):
try:
len(ids)
except TypeError:
try:
it = iter(ids)
except TypeError:
raise TypeError("ids must be a callable, sequence or generator")
else:
import itertools

new_ids = list(itertools.islice(it, len(parameters)))
else:
new_ids = list(ids)

if len(new_ids) != len(parameters):
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
fail(msg.format(func_name, len(parameters), len(ids)), pytrace=False)
for idx, id_value in enumerate(new_ids):
if id_value is not None:
if isinstance(id_value, (float, int, bool)):
new_ids[idx] = str(id_value)
elif not isinstance(id_value, str):
from _pytest._io.saferepr import saferepr

msg = "In {}: ids must be list of string/float/int/bool, found: {} (type: {!r}) at index {}"
fail(
msg.format(func_name, saferepr(id_value), type(id_value)),
msg.format(func_name, saferepr(id_value), type(id_value), idx),
pytrace=False,
)
ids = idmaker(argnames, parameters, idfn, ids, self.config, item=item)
return ids
return new_ids

def _resolve_arg_value_types(self, argnames, indirect):
"""Resolves if each parametrized argument must be considered a parameter to a fixture or a "funcarg"
Expand Down
92 changes: 88 additions & 4 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import pytest
from _pytest import fixtures
from _pytest import python
from _pytest.outcomes import fail


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

with pytest.raises(
TypeError, match="^ids must be a callable, sequence or generator$"
):
metafunc.parametrize("y", [5, 6], ids=42)

def test_parametrize_error_iterator(self):
def func(x):
raise NotImplementedError()

class Exc(Exception):
def __repr__(self):
return "Exc(from_gen)"

def gen():
yield 0
yield None
yield Exc()

metafunc = self.Metafunc(func)
metafunc.parametrize("x", [1, 2], ids=gen())
assert [(x.funcargs, x.id) for x in metafunc._calls] == [
({"x": 1}, "0"),
({"x": 2}, "2"),
]
with pytest.raises(
fail.Exception,
match=(
r"In func: ids must be list of string/float/int/bool, found:"
r" Exc\(from_gen\) \(type: <class .*Exc'>\) at index 2"
),
):
metafunc.parametrize("x", [1, 2, 3], ids=gen())

def test_parametrize_bad_scope(self, testdir):
def func(x):
pass
Expand Down Expand Up @@ -521,9 +555,22 @@ def ids(d):
@pytest.mark.parametrize("arg", ({1: 2}, {3, 4}), ids=ids)
def test(arg):
assert arg
@pytest.mark.parametrize("arg", (1, 2.0, True), ids=ids)
def test_int(arg):
assert arg
"""
)
assert testdir.runpytest().ret == 0
result = testdir.runpytest("-vv", "-s")
result.stdout.fnmatch_lines(
[
"test_parametrize_ids_returns_non_string.py::test[arg0] PASSED",
"test_parametrize_ids_returns_non_string.py::test[arg1] PASSED",
"test_parametrize_ids_returns_non_string.py::test_int[1] PASSED",
"test_parametrize_ids_returns_non_string.py::test_int[2.0] PASSED",
"test_parametrize_ids_returns_non_string.py::test_int[True] PASSED",
]
)

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

def test_parametrized_ids_invalid_type(self, testdir):
"""Tests parametrized with ids as non-strings (#1857)."""
"""Test error with non-strings/non-ints, without generator (#1857)."""
testdir.makepyfile(
"""
import pytest
@pytest.mark.parametrize("x, expected", [(10, 20), (40, 80)], ids=(None, 2))
@pytest.mark.parametrize("x, expected", [(1, 2), (3, 4), (5, 6)], ids=(None, 2, type))
def test_ids_numbers(x,expected):
assert x * 2 == expected
"""
)
result = testdir.runpytest()
result.stdout.fnmatch_lines(
[
"*In test_ids_numbers: ids must be list of strings, found: 2 (type: *'int'>)*"
"In test_ids_numbers: ids must be list of string/float/int/bool,"
" found: <class 'type'> (type: <class 'type'>) at index 2"
]
)

Expand Down Expand Up @@ -1784,3 +1832,39 @@ def test_foo(a):
)
result = testdir.runpytest()
result.assert_outcomes(passed=1)

def test_parametrize_iterator(self, testdir):
testdir.makepyfile(
"""
import itertools
import pytest
id_parametrize = pytest.mark.parametrize(
ids=("param%d" % i for i in itertools.count()
))
@id_parametrize('y', ['a', 'b'])
def test1(y):
pass
@id_parametrize('y', ['a', 'b'])
def test2(y):
pass
@pytest.mark.parametrize("a, b", [(1, 2), (3, 4)], ids=itertools.count(1))
def test_converted_to_str(a, b):
pass
"""
)
result = testdir.runpytest("-vv", "-s")
result.stdout.fnmatch_lines(
[
"test_parametrize_iterator.py::test1[param0] PASSED",
"test_parametrize_iterator.py::test1[param1] PASSED",
"test_parametrize_iterator.py::test2[param2] PASSED",
"test_parametrize_iterator.py::test2[param3] PASSED",
"test_parametrize_iterator.py::test_converted_to_str[1] PASSED",
"test_parametrize_iterator.py::test_converted_to_str[2] PASSED",
"*= 6 passed in *",
]
)

0 comments on commit 375d06c

Please sign in to comment.