Skip to content

Commit 6910ee1

Browse files
committed
Allow parametrize to depend on params and marks from previous parametrize
1 parent d126389 commit 6910ee1

File tree

6 files changed

+247
-51
lines changed

6 files changed

+247
-51
lines changed

src/_pytest/fixtures.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@
7777

7878

7979
if TYPE_CHECKING:
80-
from _pytest.python import CallSpec2
80+
from _pytest.python import CallSpec
8181
from _pytest.python import Function
8282
from _pytest.python import Metafunc
8383

@@ -184,7 +184,7 @@ def get_parametrized_fixture_argkeys(
184184
assert scope is not Scope.Function
185185

186186
try:
187-
callspec: CallSpec2 = item.callspec # type: ignore[attr-defined]
187+
callspec: CallSpec = item.callspec # type: ignore[attr-defined]
188188
except AttributeError:
189189
return
190190

src/_pytest/mark/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .structures import MarkDecorator
2020
from .structures import MarkGenerator
2121
from .structures import ParameterSet
22+
from .structures import RawParameterSet
2223
from _pytest.config import Config
2324
from _pytest.config import ExitCode
2425
from _pytest.config import hookimpl
@@ -38,6 +39,7 @@
3839
"MarkDecorator",
3940
"MarkGenerator",
4041
"ParameterSet",
42+
"RawParameterSet",
4143
"get_empty_parameterset_mark",
4244
]
4345

src/_pytest/mark/structures.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333

3434
if TYPE_CHECKING:
35+
from typing_extensions import TypeAlias
36+
3537
from ..nodes import Node
3638

3739

@@ -65,6 +67,9 @@ def get_empty_parameterset_mark(
6567
return mark
6668

6769

70+
RawParameterSet: TypeAlias = "ParameterSet | Sequence[object] | object"
71+
72+
6873
class ParameterSet(NamedTuple):
6974
values: Sequence[object | NotSetType]
7075
marks: Collection[MarkDecorator | Mark]
@@ -95,7 +100,7 @@ def param(
95100
@classmethod
96101
def extract_from(
97102
cls,
98-
parameterset: ParameterSet | Sequence[object] | object,
103+
parameterset: RawParameterSet,
99104
force_tuple: bool = False,
100105
) -> ParameterSet:
101106
"""Extract from an object or objects.
@@ -123,7 +128,6 @@ def extract_from(
123128
@staticmethod
124129
def _parse_parametrize_args(
125130
argnames: str | Sequence[str],
126-
argvalues: Iterable[ParameterSet | Sequence[object] | object],
127131
*args,
128132
**kwargs,
129133
) -> tuple[Sequence[str], bool]:
@@ -136,7 +140,7 @@ def _parse_parametrize_args(
136140

137141
@staticmethod
138142
def _parse_parametrize_parameters(
139-
argvalues: Iterable[ParameterSet | Sequence[object] | object],
143+
argvalues: Iterable[RawParameterSet],
140144
force_tuple: bool,
141145
) -> list[ParameterSet]:
142146
return [
@@ -147,12 +151,12 @@ def _parse_parametrize_parameters(
147151
def _for_parametrize(
148152
cls,
149153
argnames: str | Sequence[str],
150-
argvalues: Iterable[ParameterSet | Sequence[object] | object],
154+
argvalues: Iterable[RawParameterSet],
151155
func,
152156
config: Config,
153157
nodeid: str,
154158
) -> tuple[Sequence[str], list[ParameterSet]]:
155-
argnames, force_tuple = cls._parse_parametrize_args(argnames, argvalues)
159+
argnames, force_tuple = cls._parse_parametrize_args(argnames)
156160
parameters = cls._parse_parametrize_parameters(argvalues, force_tuple)
157161
del argvalues
158162

@@ -467,7 +471,7 @@ class _ParametrizeMarkDecorator(MarkDecorator):
467471
def __call__( # type: ignore[override]
468472
self,
469473
argnames: str | Sequence[str],
470-
argvalues: Iterable[ParameterSet | Sequence[object] | object],
474+
argvalues: Iterable[RawParameterSet],
471475
*,
472476
indirect: bool | Sequence[str] = ...,
473477
ids: Iterable[None | str | float | int | bool]

src/_pytest/python.py

+77-43
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from pathlib import Path
2323
import re
2424
import types
25+
import typing
2526
from typing import Any
2627
from typing import final
2728
from typing import Literal
@@ -56,6 +57,7 @@
5657
from _pytest.fixtures import get_scope_node
5758
from _pytest.main import Session
5859
from _pytest.mark import ParameterSet
60+
from _pytest.mark import RawParameterSet
5961
from _pytest.mark.structures import get_unpacked_marks
6062
from _pytest.mark.structures import Mark
6163
from _pytest.mark.structures import MarkDecorator
@@ -105,6 +107,7 @@ def pytest_addoption(parser: Parser) -> None:
105107
)
106108

107109

110+
@hookimpl(tryfirst=True)
108111
def pytest_generate_tests(metafunc: Metafunc) -> None:
109112
for marker in metafunc.definition.iter_markers(name="parametrize"):
110113
metafunc.parametrize(*marker.args, **marker.kwargs, _param_mark=marker)
@@ -1022,27 +1025,34 @@ def _idval_from_argname(argname: str, idx: int) -> str:
10221025

10231026
@final
10241027
@dataclasses.dataclass(frozen=True)
1025-
class CallSpec2:
1028+
class CallSpec:
10261029
"""A planned parameterized invocation of a test function.
10271030
1028-
Calculated during collection for a given test function's Metafunc.
1029-
Once collection is over, each callspec is turned into a single Item
1030-
and stored in item.callspec.
1031+
Calculated during collection for a given test function's ``Metafunc``.
1032+
Once collection is over, each callspec is turned into a single ``Item``
1033+
and stored in ``item.callspec``.
10311034
"""
10321035

1033-
# arg name -> arg value which will be passed to a fixture or pseudo-fixture
1034-
# of the same name. (indirect or direct parametrization respectively)
1035-
params: dict[str, object] = dataclasses.field(default_factory=dict)
1036-
# arg name -> arg index.
1037-
indices: dict[str, int] = dataclasses.field(default_factory=dict)
1036+
#: arg name -> arg value which will be passed to a fixture or pseudo-fixture
1037+
#: of the same name. (indirect or direct parametrization respectively)
1038+
params: Mapping[str, object] = dataclasses.field(default_factory=dict)
1039+
#: arg name -> arg index.
1040+
indices: Mapping[str, int] = dataclasses.field(default_factory=dict)
1041+
#: Marks which will be applied to the item.
1042+
marks: Sequence[Mark] = dataclasses.field(default_factory=list)
1043+
10381044
# Used for sorting parametrized resources.
10391045
_arg2scope: Mapping[str, Scope] = dataclasses.field(default_factory=dict)
10401046
# Parts which will be added to the item's name in `[..]` separated by "-".
10411047
_idlist: Sequence[str] = dataclasses.field(default_factory=tuple)
1042-
# Marks which will be applied to the item.
1043-
marks: list[Mark] = dataclasses.field(default_factory=list)
1048+
# Make __init__ internal.
1049+
_ispytest: dataclasses.InitVar[bool] = False
1050+
1051+
def __post_init__(self, _ispytest: bool):
1052+
""":meta private:"""
1053+
check_ispytest(_ispytest)
10441054

1045-
def setmulti(
1055+
def _setmulti(
10461056
self,
10471057
*,
10481058
argnames: Iterable[str],
@@ -1051,32 +1061,35 @@ def setmulti(
10511061
marks: Iterable[Mark | MarkDecorator],
10521062
scope: Scope,
10531063
param_index: int,
1054-
) -> CallSpec2:
1055-
params = self.params.copy()
1056-
indices = self.indices.copy()
1064+
) -> CallSpec:
1065+
params = dict(self.params)
1066+
indices = dict(self.indices)
10571067
arg2scope = dict(self._arg2scope)
10581068
for arg, val in zip(argnames, valset):
10591069
if arg in params:
10601070
raise ValueError(f"duplicate parametrization of {arg!r}")
10611071
params[arg] = val
10621072
indices[arg] = param_index
10631073
arg2scope[arg] = scope
1064-
return CallSpec2(
1074+
return CallSpec(
10651075
params=params,
10661076
indices=indices,
1077+
marks=[*self.marks, *normalize_mark_list(marks)],
10671078
_arg2scope=arg2scope,
10681079
_idlist=[*self._idlist, id],
1069-
marks=[*self.marks, *normalize_mark_list(marks)],
1080+
_ispytest=True,
10701081
)
10711082

10721083
def getparam(self, name: str) -> object:
1084+
""":meta private:"""
10731085
try:
10741086
return self.params[name]
10751087
except KeyError as e:
10761088
raise ValueError(name) from e
10771089

10781090
@property
10791091
def id(self) -> str:
1092+
"""The combined display name of ``params``."""
10801093
return "-".join(self._idlist)
10811094

10821095

@@ -1130,14 +1143,15 @@ def __init__(
11301143
self._arg2fixturedefs = fixtureinfo.name2fixturedefs
11311144

11321145
# Result of parametrize().
1133-
self._calls: list[CallSpec2] = []
1146+
self._calls: list[CallSpec] = []
11341147

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

11371150
def parametrize(
11381151
self,
11391152
argnames: str | Sequence[str],
1140-
argvalues: Iterable[ParameterSet | Sequence[object] | object],
1153+
argvalues: Iterable[RawParameterSet]
1154+
| Callable[[CallSpec], Iterable[RawParameterSet]],
11411155
indirect: bool | Sequence[str] = False,
11421156
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
11431157
scope: _ScopeName | None = None,
@@ -1171,7 +1185,7 @@ def parametrize(
11711185
If N argnames were specified, argvalues must be a list of
11721186
N-tuples, where each tuple-element specifies a value for its
11731187
respective argname.
1174-
:type argvalues: Iterable[_pytest.mark.structures.ParameterSet | Sequence[object] | object]
1188+
:type argvalues: Iterable[_pytest.mark.structures.ParameterSet | Sequence[object] | object] | Callable
11751189
:param indirect:
11761190
A list of arguments' names (subset of argnames) or a boolean.
11771191
If True the list contains all names from the argnames. Each
@@ -1206,13 +1220,19 @@ def parametrize(
12061220
It will also override any fixture-function defined scope, allowing
12071221
to set a dynamic scope using test context or configuration.
12081222
"""
1209-
argnames, parametersets = ParameterSet._for_parametrize(
1210-
argnames,
1211-
argvalues,
1212-
self.function,
1213-
self.config,
1214-
nodeid=self.definition.nodeid,
1215-
)
1223+
if callable(argvalues):
1224+
raw_argnames = argnames
1225+
param_factory = argvalues
1226+
argnames, _ = ParameterSet._parse_parametrize_args(raw_argnames)
1227+
else:
1228+
param_factory = None
1229+
argnames, parametersets = ParameterSet._for_parametrize(
1230+
argnames,
1231+
argvalues,
1232+
self.function,
1233+
self.config,
1234+
nodeid=self.definition.nodeid,
1235+
)
12161236
del argvalues
12171237

12181238
if "request" in argnames:
@@ -1230,19 +1250,22 @@ def parametrize(
12301250

12311251
self._validate_if_using_arg_names(argnames, indirect)
12321252

1233-
# Use any already (possibly) generated ids with parametrize Marks.
1234-
if _param_mark and _param_mark._param_ids_from:
1235-
generated_ids = _param_mark._param_ids_from._param_ids_generated
1236-
if generated_ids is not None:
1237-
ids = generated_ids
1253+
if param_factory is None:
1254+
# Use any already (possibly) generated ids with parametrize Marks.
1255+
if _param_mark and _param_mark._param_ids_from:
1256+
generated_ids = _param_mark._param_ids_from._param_ids_generated
1257+
if generated_ids is not None:
1258+
ids = generated_ids
12381259

1239-
ids = self._resolve_parameter_set_ids(
1240-
argnames, ids, parametersets, nodeid=self.definition.nodeid
1241-
)
1260+
ids_ = self._resolve_parameter_set_ids(
1261+
argnames, ids, parametersets, nodeid=self.definition.nodeid
1262+
)
12421263

1243-
# Store used (possibly generated) ids with parametrize Marks.
1244-
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
1245-
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
1264+
# Store used (possibly generated) ids with parametrize Marks.
1265+
if _param_mark and _param_mark._param_ids_from and generated_ids is None:
1266+
object.__setattr__(
1267+
_param_mark._param_ids_from, "_param_ids_generated", ids_
1268+
)
12461269

12471270
# Add funcargs as fixturedefs to fixtureinfo.arg2fixturedefs by registering
12481271
# artificial "pseudo" FixtureDef's so that later at test execution time we can
@@ -1301,11 +1324,22 @@ def parametrize(
13011324
# more than once) then we accumulate those calls generating the cartesian product
13021325
# of all calls.
13031326
newcalls = []
1304-
for callspec in self._calls or [CallSpec2()]:
1327+
for callspec in self._calls or [CallSpec(_ispytest=True)]:
1328+
if param_factory:
1329+
_, parametersets = ParameterSet._for_parametrize(
1330+
raw_argnames,
1331+
param_factory(callspec),
1332+
self.function,
1333+
self.config,
1334+
nodeid=self.definition.nodeid,
1335+
)
1336+
ids_ = self._resolve_parameter_set_ids(
1337+
argnames, ids, parametersets, nodeid=self.definition.nodeid
1338+
)
13051339
for param_index, (param_id, param_set) in enumerate(
1306-
zip(ids, parametersets)
1340+
zip(ids_, parametersets)
13071341
):
1308-
newcallspec = callspec.setmulti(
1342+
newcallspec = callspec._setmulti(
13091343
argnames=argnames,
13101344
valset=param_set.values,
13111345
id=param_id,
@@ -1453,7 +1487,7 @@ def _recompute_direct_params_indices(self) -> None:
14531487
for argname, param_type in self._params_directness.items():
14541488
if param_type == "direct":
14551489
for i, callspec in enumerate(self._calls):
1456-
callspec.indices[argname] = i
1490+
typing.cast(dict[str, int], callspec.indices)[argname] = i
14571491

14581492

14591493
def _find_parametrized_scope(
@@ -1538,7 +1572,7 @@ def __init__(
15381572
name: str,
15391573
parent,
15401574
config: Config | None = None,
1541-
callspec: CallSpec2 | None = None,
1575+
callspec: CallSpec | None = None,
15421576
callobj=NOTSET,
15431577
keywords: Mapping[str, Any] | None = None,
15441578
session: Session | None = None,

src/pytest/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from _pytest.pytester import Pytester
5454
from _pytest.pytester import RecordedHookCall
5555
from _pytest.pytester import RunResult
56+
from _pytest.python import CallSpec
5657
from _pytest.python import Class
5758
from _pytest.python import Function
5859
from _pytest.python import Metafunc
@@ -91,6 +92,7 @@
9192
__all__ = [
9293
"Cache",
9394
"CallInfo",
95+
"CallSpec",
9496
"CaptureFixture",
9597
"Class",
9698
"CollectReport",

0 commit comments

Comments
 (0)