Skip to content

Commit 6b6502e

Browse files
authored
Merge pull request #13976 from bluetech/duplicate-parametrization-fix
fixtures: fix incorrect "duplicate parametrization" when using `indirect=[...]`
2 parents fd47511 + adcdeb9 commit 6b6502e

File tree

4 files changed

+121
-93
lines changed

4 files changed

+121
-93
lines changed

src/_pytest/fixtures.py

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from typing import Final
2626
from typing import final
2727
from typing import Generic
28+
from typing import Literal
2829
from typing import NoReturn
2930
from typing import overload
3031
from typing import TYPE_CHECKING
@@ -1472,6 +1473,45 @@ def pytest_cmdline_main(config: Config) -> int | ExitCode | None:
14721473
return None
14731474

14741475

1476+
def _resolve_args_directness(
1477+
argnames: Sequence[str],
1478+
indirect: bool | Sequence[str],
1479+
nodeid: str,
1480+
) -> dict[str, Literal["indirect", "direct"]]:
1481+
"""Resolve if each parametrized argument must be considered an indirect
1482+
parameter to a fixture of the same name, or a direct parameter to the
1483+
parametrized function, based on the ``indirect`` parameter of the
1484+
parametrize() call.
1485+
1486+
:param argnames:
1487+
List of argument names passed to ``parametrize()``.
1488+
:param indirect:
1489+
Same as the ``indirect`` parameter of ``parametrize()``.
1490+
:param nodeid:
1491+
Node ID to which the parametrization is applied.
1492+
:returns:
1493+
A dict mapping each arg name to either "indirect" or "direct".
1494+
"""
1495+
arg_directness: dict[str, Literal["indirect", "direct"]]
1496+
if isinstance(indirect, bool):
1497+
arg_directness = dict.fromkeys(argnames, "indirect" if indirect else "direct")
1498+
elif isinstance(indirect, Sequence):
1499+
arg_directness = dict.fromkeys(argnames, "direct")
1500+
for arg in indirect:
1501+
if arg not in argnames:
1502+
fail(
1503+
f"In {nodeid}: indirect fixture '{arg}' doesn't exist",
1504+
pytrace=False,
1505+
)
1506+
arg_directness[arg] = "indirect"
1507+
else:
1508+
fail(
1509+
f"In {nodeid}: expected Sequence or boolean for indirect, got {type(indirect).__name__}",
1510+
pytrace=False,
1511+
)
1512+
return arg_directness
1513+
1514+
14751515
def _get_direct_parametrize_args(node: nodes.Node) -> set[str]:
14761516
"""Return all direct parametrization arguments of a node, so we don't
14771517
mistake them for fixtures.
@@ -1483,11 +1523,16 @@ def _get_direct_parametrize_args(node: nodes.Node) -> set[str]:
14831523
"""
14841524
parametrize_argnames: set[str] = set()
14851525
for marker in node.iter_markers(name="parametrize"):
1486-
if not marker.kwargs.get("indirect", False):
1487-
p_argnames, _ = ParameterSet._parse_parametrize_args(
1488-
*marker.args, **marker.kwargs
1489-
)
1490-
parametrize_argnames.update(p_argnames)
1526+
indirect = marker.kwargs.get("indirect", False)
1527+
p_argnames, _ = ParameterSet._parse_parametrize_args(
1528+
*marker.args, **marker.kwargs
1529+
)
1530+
p_directness = _resolve_args_directness(p_argnames, indirect, node.nodeid)
1531+
parametrize_argnames.update(
1532+
argname
1533+
for argname, directness in p_directness.items()
1534+
if directness == "direct"
1535+
)
14911536
return parametrize_argnames
14921537

14931538

src/_pytest/python.py

Lines changed: 13 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
from _pytest.config import hookimpl
5454
from _pytest.config.argparsing import Parser
5555
from _pytest.deprecated import check_ispytest
56+
from _pytest.fixtures import _resolve_args_directness
5657
from _pytest.fixtures import FixtureDef
5758
from _pytest.fixtures import FixtureRequest
5859
from _pytest.fixtures import FuncFixtureInfo
@@ -870,7 +871,6 @@ class IdMaker:
870871
__slots__ = (
871872
"argnames",
872873
"config",
873-
"func_name",
874874
"idfn",
875875
"ids",
876876
"nodeid",
@@ -893,9 +893,6 @@ class IdMaker:
893893
# Optionally, the ID of the node being parametrized.
894894
# Used only for clearer error messages.
895895
nodeid: str | None
896-
# Optionally, the ID of the function being parametrized.
897-
# Used only for clearer error messages.
898-
func_name: str | None
899896

900897
def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
901898
"""Make a unique identifier for each ParameterSet, that may be used to
@@ -1083,9 +1080,7 @@ def _complain_multiple_hidden_parameter_sets(self) -> NoReturn:
10831080
)
10841081

10851082
def _make_error_prefix(self) -> str:
1086-
if self.func_name is not None:
1087-
return f"In {self.func_name}: "
1088-
elif self.nodeid is not None:
1083+
if self.nodeid is not None:
10891084
return f"In {self.nodeid}: "
10901085
else:
10911086
return ""
@@ -1333,7 +1328,9 @@ def parametrize(
13331328
object.__setattr__(_param_mark._param_ids_from, "_param_ids_generated", ids)
13341329

13351330
# Calculate directness.
1336-
arg_directness = self._resolve_args_directness(argnames, indirect)
1331+
arg_directness = _resolve_args_directness(
1332+
argnames, indirect, self.definition.nodeid
1333+
)
13371334
self._params_directness.update(arg_directness)
13381335

13391336
# Add direct parametrizations as fixturedefs to arg2fixturedefs by
@@ -1435,23 +1432,21 @@ def _resolve_parameter_set_ids(
14351432
ids_ = None
14361433
else:
14371434
idfn = None
1438-
ids_ = self._validate_ids(ids, parametersets, self.function.__name__)
1435+
ids_ = self._validate_ids(ids, parametersets)
14391436
id_maker = IdMaker(
14401437
argnames,
14411438
parametersets,
14421439
idfn,
14431440
ids_,
14441441
self.config,
14451442
nodeid=nodeid,
1446-
func_name=self.function.__name__,
14471443
)
14481444
return id_maker.make_unique_parameterset_ids()
14491445

14501446
def _validate_ids(
14511447
self,
14521448
ids: Iterable[object | None],
14531449
parametersets: Sequence[ParameterSet],
1454-
func_name: str,
14551450
) -> list[object | None]:
14561451
try:
14571452
num_ids = len(ids) # type: ignore[arg-type]
@@ -1464,49 +1459,13 @@ def _validate_ids(
14641459

14651460
# num_ids == 0 is a special case: https://github.com/pytest-dev/pytest/issues/1849
14661461
if num_ids != len(parametersets) and num_ids != 0:
1467-
msg = "In {}: {} parameter sets specified, with different number of ids: {}"
1468-
fail(msg.format(func_name, len(parametersets), num_ids), pytrace=False)
1469-
1470-
return list(itertools.islice(ids, num_ids))
1471-
1472-
def _resolve_args_directness(
1473-
self,
1474-
argnames: Sequence[str],
1475-
indirect: bool | Sequence[str],
1476-
) -> dict[str, Literal["indirect", "direct"]]:
1477-
"""Resolve if each parametrized argument must be considered an indirect
1478-
parameter to a fixture of the same name, or a direct parameter to the
1479-
parametrized function, based on the ``indirect`` parameter of the
1480-
parametrized() call.
1481-
1482-
:param argnames:
1483-
List of argument names passed to ``parametrize()``.
1484-
:param indirect:
1485-
Same as the ``indirect`` parameter of ``parametrize()``.
1486-
:returns
1487-
A dict mapping each arg name to either "indirect" or "direct".
1488-
"""
1489-
arg_directness: dict[str, Literal["indirect", "direct"]]
1490-
if isinstance(indirect, bool):
1491-
arg_directness = dict.fromkeys(
1492-
argnames, "indirect" if indirect else "direct"
1493-
)
1494-
elif isinstance(indirect, Sequence):
1495-
arg_directness = dict.fromkeys(argnames, "direct")
1496-
for arg in indirect:
1497-
if arg not in argnames:
1498-
fail(
1499-
f"In {self.function.__name__}: indirect fixture '{arg}' doesn't exist",
1500-
pytrace=False,
1501-
)
1502-
arg_directness[arg] = "indirect"
1503-
else:
1462+
nodeid = self.definition.nodeid
15041463
fail(
1505-
f"In {self.function.__name__}: expected Sequence or boolean"
1506-
f" for indirect, got {type(indirect).__name__}",
1464+
f"In {nodeid}: {len(parametersets)} parameter sets specified, with different number of ids: {num_ids}",
15071465
pytrace=False,
15081466
)
1509-
return arg_directness
1467+
1468+
return list(itertools.islice(ids, num_ids))
15101469

15111470
def _validate_if_using_arg_names(
15121471
self,
@@ -1520,12 +1479,12 @@ def _validate_if_using_arg_names(
15201479
:raises ValueError: If validation fails.
15211480
"""
15221481
default_arg_names = set(get_default_arg_names(self.function))
1523-
func_name = self.function.__name__
1482+
nodeid = self.definition.nodeid
15241483
for arg in argnames:
15251484
if arg not in self.fixturenames:
15261485
if arg in default_arg_names:
15271486
fail(
1528-
f"In {func_name}: function already takes an argument '{arg}' with a default value",
1487+
f"In {nodeid}: function already takes an argument '{arg}' with a default value",
15291488
pytrace=False,
15301489
)
15311490
else:
@@ -1534,7 +1493,7 @@ def _validate_if_using_arg_names(
15341493
else:
15351494
name = "fixture" if indirect else "argument"
15361495
fail(
1537-
f"In {func_name}: function uses no {name} '{arg}'",
1496+
f"In {nodeid}: function uses no {name} '{arg}'",
15381497
pytrace=False,
15391498
)
15401499

testing/python/collect.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,42 @@ def test_overridden_via_param(value):
524524
rec = pytester.inline_run()
525525
rec.assertoutcome(passed=1)
526526

527+
def test_parametrize_overrides_parametrized_fixture_with_unrelated_indirect(
528+
self, pytester: Pytester
529+
) -> None:
530+
"""Test parametrization when parameter overrides existing parametrized fixture with same name,
531+
and there is an unrelated indirect param.
532+
533+
Regression test for #13974.
534+
"""
535+
pytester.makepyfile(
536+
"""
537+
import pytest
538+
539+
@pytest.fixture(params=["a", "b"])
540+
def target(request):
541+
return request.param
542+
543+
@pytest.fixture
544+
def val(request):
545+
return int(request.param)
546+
547+
@pytest.mark.parametrize(
548+
["val", "target"],
549+
[
550+
("1", 1),
551+
("2", 2),
552+
],
553+
indirect=["val"],
554+
)
555+
def test(val, target):
556+
assert val == target
557+
"""
558+
)
559+
result = pytester.runpytest()
560+
assert result.ret == 0
561+
result.assert_outcomes(passed=2)
562+
527563
def test_parametrize_overrides_indirect_dependency_fixture(
528564
self, pytester: Pytester
529565
) -> None:

0 commit comments

Comments
 (0)