Skip to content

Commit 02e5363

Browse files
committed
Deprecate passing iterator argvalues to parametrize
Fix #13409.
1 parent 2f5743d commit 02e5363

File tree

8 files changed

+250
-4
lines changed

8 files changed

+250
-4
lines changed

changelog/13409.deprecation.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Using non-:class:`~collections.abc.Collection` iterables (such as generators, iterators, or custom iterable objects) for the ``argvalues`` parameter in :ref:`@pytest.mark.parametrize <pytest.mark.parametrize ref>` and :meth:`metafunc.parametrize <pytest.Metafunc.parametrize>` is now deprecated.
2+
3+
These iterables get exhausted after the first iteration,
4+
leading to tests getting unexpectedly skipped in cases such as running :func:`pytest.main()` multiple times,
5+
using class-level parametrize decorators,
6+
or collecting tests multiple times.
7+
8+
See :ref:`parametrize-iterators` for details and suggestions.

doc/en/deprecations.rst

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,67 @@ Below is a complete list of all pytest features which are considered deprecated.
1515
:class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters <warnings>`.
1616

1717

18+
.. _parametrize-iterators:
19+
20+
Non-Collection iterables in ``@pytest.mark.parametrize``
21+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
22+
23+
.. deprecated:: 9.1
24+
25+
Using non-:class:`~collections.abc.Collection` iterables (such as generators, iterators, or custom iterable objects)
26+
for the ``argvalues`` parameter in :ref:`@pytest.mark.parametrize <pytest.mark.parametrize ref>`
27+
and :meth:`metafunc.parametrize <pytest.Metafunc.parametrize>` is deprecated.
28+
29+
These iterables get exhausted after the first iteration, leads to tests getting unexpectedly skipped in cases such as:
30+
31+
* Running :func`pytest.main()` multiple times in the same process
32+
* Using class-level parametrize decorators where the same mark is applied to multiple test methods
33+
* Collecting tests multiple times
34+
35+
Example of problematic code:
36+
37+
.. code-block:: python
38+
39+
import pytest
40+
41+
42+
def data_generator():
43+
yield 1
44+
yield 2
45+
46+
47+
@pytest.mark.parametrize("n", data_generator())
48+
class Test:
49+
def test_1(self, n):
50+
pass
51+
52+
# test_2 will be skipped because data_generator() is exhausted.
53+
def test_2(self, n):
54+
pass
55+
56+
You can fix it by convert generators and iterators to lists or tuples:
57+
58+
.. code-block:: python
59+
60+
import pytest
61+
62+
63+
def data_generator():
64+
yield 1
65+
yield 2
66+
67+
68+
@pytest.mark.parametrize("n", list(data_generator()))
69+
class Test:
70+
def test_1(self, n):
71+
pass
72+
73+
def test_2(self, n):
74+
pass
75+
76+
Note that :class:`range` objects are ``Collection`` and are not affected by this deprecation.
77+
78+
1879
.. _monkeypatch-fixup-namespace-packages:
1980

2081
``monkeypatch.syspath_prepend`` with legacy namespace packages

src/_pytest/compat.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from typing import Any
1616
from typing import Final
1717
from typing import NoReturn
18+
from typing import TYPE_CHECKING
1819

1920
import py
2021

@@ -311,3 +312,17 @@ def running_on_ci() -> bool:
311312
# Only enable CI mode if one of these env variables is defined and non-empty.
312313
env_vars = ["CI", "BUILD_NUMBER"]
313314
return any(os.environ.get(var) for var in env_vars)
315+
316+
317+
if sys.version_info >= (3, 13):
318+
from warnings import deprecated as deprecated
319+
else:
320+
if TYPE_CHECKING:
321+
from typing_extensions import deprecated as deprecated
322+
else:
323+
324+
def deprecated(msg, /, *, category=None, stacklevel=1):
325+
def decorator(func):
326+
return func
327+
328+
return decorator

src/_pytest/deprecated.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@
7575
"See https://docs.pytest.org/en/stable/deprecations.html#monkeypatch-fixup-namespace-packages"
7676
)
7777

78+
PARAMETRIZE_NON_COLLECTION_ITERABLE = UnformattedWarning(
79+
PytestRemovedIn10Warning,
80+
"The {param_name} parameter in parametrize uses a non-Collection iterable of type '{type_name}'.\n"
81+
"Please convert to a list or tuple.\n"
82+
"See https://docs.pytest.org/en/stable/deprecations.html#parametrize-iterators",
83+
)
84+
7885
# You want to make some `__init__` or function "private".
7986
#
8087
# def my_private_function(some, args):

src/_pytest/mark/structures.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,13 @@
2121
import warnings
2222

2323
from .._code import getfslineno
24+
from ..compat import deprecated
2425
from ..compat import NOTSET
2526
from ..compat import NotSetType
2627
from _pytest.config import Config
2728
from _pytest.deprecated import check_ispytest
2829
from _pytest.deprecated import MARKED_FIXTURE
30+
from _pytest.deprecated import PARAMETRIZE_NON_COLLECTION_ITERABLE
2931
from _pytest.outcomes import fail
3032
from _pytest.raises import AbstractRaises
3133
from _pytest.scope import _ScopeName
@@ -193,6 +195,15 @@ def _for_parametrize(
193195
config: Config,
194196
nodeid: str,
195197
) -> tuple[Sequence[str], list[ParameterSet]]:
198+
if not isinstance(argvalues, Collection):
199+
warnings.warn(
200+
PARAMETRIZE_NON_COLLECTION_ITERABLE.format(
201+
param_name="argvalues",
202+
type_name=type(argvalues).__name__,
203+
),
204+
stacklevel=3,
205+
)
206+
196207
argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues)
197208
parameters = cls._parse_parametrize_parameters(argvalues, force_tuple)
198209
del argvalues
@@ -508,7 +519,25 @@ def __call__(
508519
) -> MarkDecorator: ...
509520

510521
class _ParametrizeMarkDecorator(MarkDecorator):
511-
def __call__( # type: ignore[override]
522+
@overload # type: ignore[override,no-overload-impl]
523+
def __call__(
524+
self,
525+
argnames: str | Sequence[str],
526+
argvalues: Collection[ParameterSet | Sequence[object] | object],
527+
*,
528+
indirect: bool | Sequence[str] = ...,
529+
ids: Iterable[None | str | float | int | bool]
530+
| Callable[[Any], object | None]
531+
| None = ...,
532+
scope: _ScopeName | None = ...,
533+
) -> MarkDecorator: ...
534+
535+
@overload
536+
@deprecated(
537+
"Passing a non-Collection iterable to the 'argvalues' parameter of @pytest.mark.parametrize is deprecated. "
538+
"Convert argvalues to a list or tuple.",
539+
)
540+
def __call__(
512541
self,
513542
argnames: str | Sequence[str],
514543
argvalues: Iterable[ParameterSet | Sequence[object] | object],

src/_pytest/python.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from collections import Counter
88
from collections import defaultdict
99
from collections.abc import Callable
10+
from collections.abc import Collection
1011
from collections.abc import Generator
1112
from collections.abc import Iterable
1213
from collections.abc import Iterator
@@ -28,6 +29,7 @@
2829
from typing import final
2930
from typing import Literal
3031
from typing import NoReturn
32+
from typing import overload
3133
from typing import TYPE_CHECKING
3234
import warnings
3335

@@ -41,6 +43,7 @@
4143
from _pytest._code.code import Traceback
4244
from _pytest._io.saferepr import saferepr
4345
from _pytest.compat import ascii_escaped
46+
from _pytest.compat import deprecated
4447
from _pytest.compat import get_default_arg_names
4548
from _pytest.compat import get_real_func
4649
from _pytest.compat import getimfunc
@@ -1209,6 +1212,34 @@ def __init__(
12091212

12101213
self._params_directness: dict[str, Literal["indirect", "direct"]] = {}
12111214

1215+
@overload
1216+
def parametrize(
1217+
self,
1218+
argnames: str | Sequence[str],
1219+
argvalues: Collection[ParameterSet | Sequence[object] | object],
1220+
indirect: bool | Sequence[str] = False,
1221+
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
1222+
scope: _ScopeName | None = None,
1223+
*,
1224+
_param_mark: Mark | None = None,
1225+
) -> None: ...
1226+
1227+
@overload
1228+
@deprecated(
1229+
"Passing a non-Collection iterable to the 'argvalues' parameter of metafunc.parametrize is deprecated. "
1230+
"Convert argvalues to a list or tuple.",
1231+
)
1232+
def parametrize(
1233+
self,
1234+
argnames: str | Sequence[str],
1235+
argvalues: Iterable[ParameterSet | Sequence[object] | object],
1236+
indirect: bool | Sequence[str] = False,
1237+
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
1238+
scope: _ScopeName | None = None,
1239+
*,
1240+
_param_mark: Mark | None = None,
1241+
) -> None: ...
1242+
12121243
def parametrize(
12131244
self,
12141245
argnames: str | Sequence[str],
@@ -1247,6 +1278,12 @@ def parametrize(
12471278
N-tuples, where each tuple-element specifies a value for its
12481279
respective argname.
12491280
1281+
.. versionchanged:: 9.1
1282+
1283+
Passing a non-:class:`~collections.abc.Collection` iterable
1284+
(such as a generator or iterator) is deprecated. See
1285+
:ref:`parametrize-iterators` for details.
1286+
12501287
:param indirect:
12511288
A list of arguments' names (subset of argnames) or a boolean.
12521289
If True the list contains all names from the argnames. Each

testing/python/metafunc.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def func(x, y):
9393
metafunc.parametrize("y", [5, 6])
9494

9595
with pytest.raises(TypeError, match=r"^ids must be a callable or an iterable$"):
96-
metafunc.parametrize("y", [5, 6], ids=42) # type: ignore[arg-type]
96+
metafunc.parametrize("y", [5, 6], ids=42) # type: ignore[call-overload]
9797

9898
def test_parametrize_error_iterator(self) -> None:
9999
def func(x):
@@ -134,7 +134,7 @@ def func(x):
134134
fail.Exception,
135135
match=r"parametrize\(\) call in func got an unexpected scope value 'doggy'",
136136
):
137-
metafunc.parametrize("x", [1], scope="doggy") # type: ignore[arg-type]
137+
metafunc.parametrize("x", [1], scope="doggy") # type: ignore[call-overload]
138138

139139
def test_parametrize_request_name(self, pytester: Pytester) -> None:
140140
"""Show proper error when 'request' is used as a parameter name in parametrize (#6183)"""
@@ -813,7 +813,7 @@ def func(x, y):
813813
fail.Exception,
814814
match="In func: expected Sequence or boolean for indirect, got dict",
815815
):
816-
metafunc.parametrize("x, y", [("a", "b")], indirect={}) # type: ignore[arg-type]
816+
metafunc.parametrize("x, y", [("a", "b")], indirect={}) # type: ignore[call-overload]
817817

818818
def test_parametrize_indirect_list_functional(self, pytester: Pytester) -> None:
819819
"""
@@ -1123,6 +1123,24 @@ def test_3(self, arg, arg2):
11231123
"""
11241124
)
11251125

1126+
def test_parametrize_iterator_deprecation(self) -> None:
1127+
"""Test that using iterators for argvalues raises a deprecation warning."""
1128+
1129+
def func(x):
1130+
pass
1131+
1132+
def data_generator():
1133+
yield 1
1134+
yield 2
1135+
1136+
metafunc = self.Metafunc(func)
1137+
1138+
with pytest.warns(
1139+
pytest.PytestRemovedIn10Warning,
1140+
match=r"The argvalues parameter.*uses a non-Collection iterable",
1141+
):
1142+
metafunc.parametrize("x", data_generator())
1143+
11261144

11271145
class TestMetafuncFunctional:
11281146
def test_attributes(self, pytester: Pytester) -> None:
@@ -1682,6 +1700,62 @@ def test_3(self, fixture):
16821700
]
16831701
)
16841702

1703+
def test_parametrize_generator_multiple_runs(self, pytester: Pytester) -> None:
1704+
"""Test that generators in parametrize work with multiple pytest.main() (deprecated)."""
1705+
pytester.makepyfile(
1706+
"""
1707+
import pytest
1708+
1709+
def data_generator():
1710+
yield 1
1711+
yield 2
1712+
1713+
@pytest.mark.parametrize("bar", data_generator())
1714+
def test_foo(bar):
1715+
pass
1716+
1717+
if __name__ == '__main__':
1718+
args = ["-q", "--collect-only"]
1719+
pytest.main(args) # First run - should work with warning
1720+
pytest.main(args) # Second run - should also work with warning
1721+
"""
1722+
)
1723+
result = pytester.runpytest("-W", "default")
1724+
# Should see the deprecation warnings.
1725+
result.stdout.fnmatch_lines(
1726+
[
1727+
"*PytestRemovedIn10Warning: The argvalues parameter*",
1728+
]
1729+
)
1730+
1731+
def test_parametrize_iterator_class_multiple_tests(
1732+
self, pytester: Pytester
1733+
) -> None:
1734+
"""Test that iterators in parametrize on a class get exhausted (deprecated)."""
1735+
pytester.makepyfile(
1736+
"""
1737+
import pytest
1738+
1739+
@pytest.mark.parametrize("n", iter(range(2)))
1740+
class Test:
1741+
def test_1(self, n):
1742+
pass
1743+
1744+
def test_2(self, n):
1745+
pass
1746+
"""
1747+
)
1748+
result = pytester.runpytest("-v", "-W", "default")
1749+
# Iterator gets exhausted after first test, second test gets no parameters.
1750+
# This is deprecated.
1751+
result.assert_outcomes(passed=2, skipped=1)
1752+
# Should see the deprecation warnings.
1753+
result.stdout.fnmatch_lines(
1754+
[
1755+
"*PytestRemovedIn10Warning: The argvalues parameter*",
1756+
]
1757+
)
1758+
16851759

16861760
class TestMetafuncFunctionalAuto:
16871761
"""Tests related to automatically find out the correct scope for

testing/typing_checks.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
from __future__ import annotations
99

1010
import contextlib
11+
from typing import cast
1112
from typing import Literal
1213

1314
from typing_extensions import assert_type
1415

1516
import pytest
17+
from pytest import Metafunc
1618
from pytest import MonkeyPatch
1719
from pytest import TestReport
1820

@@ -58,3 +60,16 @@ def check_raises_is_a_context_manager(val: bool) -> None:
5860
def check_testreport_attributes(report: TestReport) -> None:
5961
assert_type(report.when, Literal["setup", "call", "teardown"])
6062
assert_type(report.location, tuple[str, int | None, str])
63+
64+
65+
# Test @pytest.mark.parametrize iterator argvalues deprecation.
66+
# Will be complain about unused type ignore if doesn't work.
67+
@pytest.mark.parametrize("x", iter(range(10))) # type: ignore[deprecated]
68+
def test_it(x: int) -> None:
69+
pass
70+
71+
72+
# Test metafunc.parametrize iterator argvalues deprecation.
73+
# Will be complain about unused type ignore if doesn't work.
74+
metafunc: Metafunc = cast(Metafunc, None)
75+
metafunc.parametrize("x", iter(range(10))) # type: ignore[deprecated]

0 commit comments

Comments
 (0)