Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog/13233.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
:hook:`pytest_generate_tests` hooks and :ref:`pytest.mark.parametrize <pytest.mark.parametrize ref>`
can now depend on previous parametrizations instead of generating a Cartesian product of parameter sets.
See details in :ref:`parametrize_dependent`.

For example, a :hook:`pytest_generate_tests` hook that relies on marks can now account for all the marks correctly.
67 changes: 67 additions & 0 deletions doc/en/example/parametrize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -688,3 +688,70 @@ For example:
In the example above, the first three test cases should run without any
exceptions, while the fourth should raise a ``ZeroDivisionError`` exception,
which is expected by pytest.

.. _`parametrize_dependent`:

Adding parameters depending on previous parametrizations
--------------------------------------------------------------------

By default, :hook:`pytest_generate_tests` hooks and
:ref:`pytest.mark.parametrize <pytest.mark.parametrize ref>` generate
a Cartesian product of parameter sets in case of multiple parametrizations,
see :ref:`parametrize-basics` for some examples.

Sometimes, values of some parameters need to be generated based on values
of previous parameters or based on their associated marks.

In such cases ``parametrize`` can be passed a callable for ``argvalues``,
which will decide how to further parametrize each test instance:

.. code-block:: python

# content of test_parametrize_dependent.py
def pytest_generate_tests(metafunc: pytest.Metafunc):
if "bar" in metafunc.fixturenames:
# parametrize "bar" arg based on "bar_params" mark
base_bar_marks = list(metafunc.definition.iter_markers("bar_params"))

def gen_params(callspec: pytest.CallSpec):
# collect all marks
bar_marks = base_bar_marks + [
mark for mark in callspec.marks if mark.name == "bar_params"
]
# collect all args from all marks
return [arg for mark in bar_marks for arg in mark.args]

metafunc.parametrize("bar", gen_params)


@pytest.mark.bar_params("x")
@pytest.mark.parametrize(
"foo",
[
"a",
pytest.param("b", marks=[pytest.mark.bar_params("y", "z")]),
pytest.param("c", marks=[pytest.mark.bar_params("w")]),
],
)
def test_function(foo, bar):
pass

Running ``pytest`` with verbose mode outputs:

.. code-block:: pytest

$ pytest -v
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y -- $PYTHON_PREFIX/bin/python
cachedir: .pytest_cache
rootdir: /home/sweet/project
collecting ... collected 6 items

test_parametrize_dependent.py::test_function[a-x] PASSED [ 16%]
test_parametrize_dependent.py::test_function[b-x] PASSED [ 33%]
test_parametrize_dependent.py::test_function[b-y] PASSED [ 50%]
test_parametrize_dependent.py::test_function[b-z] PASSED [ 66%]
test_parametrize_dependent.py::test_function[c-x] PASSED [ 83%]
test_parametrize_dependent.py::test_function[c-w] PASSED [100%]

============================ 6 passed in 0.12s =============================
6 changes: 6 additions & 0 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,12 @@ Objects accessible from :ref:`fixtures <fixture>` or :ref:`hooks <hook-reference
or importable from ``pytest``.


CallSpec
~~~~~~~~

.. autoclass:: pytest.CallSpec()
:members:

CallInfo
~~~~~~~~

Expand Down
4 changes: 2 additions & 2 deletions src/_pytest/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@


if TYPE_CHECKING:
from _pytest.python import CallSpec2
from _pytest.python import CallSpec
from _pytest.python import Function
from _pytest.python import Metafunc

Expand Down Expand Up @@ -184,7 +184,7 @@ def get_parametrized_fixture_argkeys(
assert scope is not Scope.Function

try:
callspec: CallSpec2 = item.callspec # type: ignore[attr-defined]
callspec: CallSpec = item.callspec # type: ignore[attr-defined]
except AttributeError:
return

Expand Down
2 changes: 2 additions & 0 deletions src/_pytest/mark/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .structures import MarkDecorator
from .structures import MarkGenerator
from .structures import ParameterSet
from .structures import RawParameterSet
from _pytest.config import Config
from _pytest.config import ExitCode
from _pytest.config import hookimpl
Expand All @@ -38,6 +39,7 @@
"MarkDecorator",
"MarkGenerator",
"ParameterSet",
"RawParameterSet",
"get_empty_parameterset_mark",
]

Expand Down
16 changes: 10 additions & 6 deletions src/_pytest/mark/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@


if TYPE_CHECKING:
from typing_extensions import TypeAlias

from ..nodes import Node


Expand Down Expand Up @@ -65,6 +67,9 @@ def get_empty_parameterset_mark(
return mark


RawParameterSet: TypeAlias = "ParameterSet | Sequence[object] | object"


class ParameterSet(NamedTuple):
values: Sequence[object | NotSetType]
marks: Collection[MarkDecorator | Mark]
Expand Down Expand Up @@ -95,7 +100,7 @@ def param(
@classmethod
def extract_from(
cls,
parameterset: ParameterSet | Sequence[object] | object,
parameterset: RawParameterSet,
force_tuple: bool = False,
) -> ParameterSet:
"""Extract from an object or objects.
Expand Down Expand Up @@ -123,7 +128,6 @@ def extract_from(
@staticmethod
def _parse_parametrize_args(
argnames: str | Sequence[str],
argvalues: Iterable[ParameterSet | Sequence[object] | object],
*args,
**kwargs,
) -> tuple[Sequence[str], bool]:
Expand All @@ -136,7 +140,7 @@ def _parse_parametrize_args(

@staticmethod
def _parse_parametrize_parameters(
argvalues: Iterable[ParameterSet | Sequence[object] | object],
argvalues: Iterable[RawParameterSet],
force_tuple: bool,
) -> list[ParameterSet]:
return [
Expand All @@ -147,12 +151,12 @@ def _parse_parametrize_parameters(
def _for_parametrize(
cls,
argnames: str | Sequence[str],
argvalues: Iterable[ParameterSet | Sequence[object] | object],
argvalues: Iterable[RawParameterSet],
func,
config: Config,
nodeid: str,
) -> tuple[Sequence[str], list[ParameterSet]]:
argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues)
argnames, force_tuple = cls._parse_parametrize_args(argnames)
parameters = cls._parse_parametrize_parameters(argvalues, force_tuple)
del argvalues

Expand Down Expand Up @@ -467,7 +471,7 @@ class _ParametrizeMarkDecorator(MarkDecorator):
def __call__( # type: ignore[override]
self,
argnames: str | Sequence[str],
argvalues: Iterable[ParameterSet | Sequence[object] | object],
argvalues: Iterable[RawParameterSet],
*,
indirect: bool | Sequence[str] = ...,
ids: Iterable[None | str | float | int | bool]
Expand Down
Loading