Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP/RFC] parametrized: ids: support generator/iterator #105

Closed
wants to merge 11 commits into from
3 changes: 3 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ jobs:
- env: TOXENV=py36-xdist PYTEST_COVERAGE=1 PYTEST_REORDER_TESTS=0
python: '3.6.9'

- env: TOXENV=py35-xdist PYTEST_COVERAGE=1
python: '3.5.6'

- env: TOXENV=linting,docs,doctesting PYTEST_COVERAGE=1
cache:
directories:
Expand Down
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 @@ -943,13 +943,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 @@ -1013,26 +1021,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 @@ -7,6 +7,7 @@
import pytest
from _pytest import fixtures
from _pytest import python
from _pytest.outcomes import fail


class TestMetafunc:
Expand Down Expand Up @@ -59,6 +60,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 @@ -508,9 +542,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 @@ -1160,20 +1207,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 @@ -1807,3 +1855,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())
def test_converted_to_str(a, b):
pass
"""
)
result = testdir.runpytest("-vv", "-s")
result.stdout.fnmatch_lines_random( # random for py35.
[
"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[0] PASSED",
"test_parametrize_iterator.py::test_converted_to_str[1] PASSED",
"*= 6 passed in *",
]
)
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ filterwarnings =
error
default:Using or importing the ABCs:DeprecationWarning:unittest2.*
default:the imp module is deprecated in favour of importlib:DeprecationWarning:nose.*
default:the imp module is deprecated in favour of importlib:PendingDeprecationWarning:imp.*
ignore:Module already imported so cannot be rewritten:pytest.PytestWarning
# produced by python3.6/site.py itself (3.6.7 on Travis, could not trigger it with 3.6.8).
ignore:.*U.*mode is deprecated:DeprecationWarning:(?!(pytest|_pytest))
Expand Down