Skip to content

Commit c0ce285

Browse files
committed
feat: add --require-unique-paramset-ids option skips pytest internal logic of id generation
1 parent 237ede4 commit c0ce285

File tree

8 files changed

+130
-51
lines changed

8 files changed

+130
-51
lines changed

changelog/13737.feature.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Added the ``--require-unique-paramset-ids`` flag to pytest.
2+
3+
When passed, this flag causes pytest to raise an exception upon detection of non-unique parameter set IDs,
4+
rather than attempting to generate them automatically.

doc/en/example/.ruff.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
ignore = ["RUF059"]
1+
lint.ignore = ["RUF059"]

doc/en/reference/reference.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2220,6 +2220,10 @@ All the command-line flags can be obtained by running ``pytest --help``::
22202220
--doctest-continue-on-failure
22212221
For a given doctest, continue to run after the first
22222222
failure
2223+
--require-unique-paramset-ids
2224+
If pytest generates non-unique parameter ids, raise an
2225+
error rather than fixing this. Useful if you collect in one
2226+
process, and then execute tests in independent workers.
22232227

22242228
test session debugging and configuration:
22252229
-c, --config-file FILE

src/_pytest/assertion/rewrite.py

Lines changed: 42 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -703,24 +703,17 @@ def run(self, mod: ast.Module) -> None:
703703
return
704704
pos = 0
705705
for item in mod.body:
706-
if (
707-
expect_docstring
708-
and isinstance(item, ast.Expr)
709-
and isinstance(item.value, ast.Constant)
710-
and isinstance(item.value.value, str)
711-
):
712-
doc = item.value.value
713-
if self.is_rewrite_disabled(doc):
714-
return
715-
expect_docstring = False
716-
elif (
717-
isinstance(item, ast.ImportFrom)
718-
and item.level == 0
719-
and item.module == "__future__"
720-
):
721-
pass
722-
else:
723-
break
706+
match item:
707+
case ast.Expr(value=ast.Constant(value=str() as doc)) if (
708+
expect_docstring
709+
):
710+
if self.is_rewrite_disabled(doc):
711+
return
712+
expect_docstring = False
713+
case ast.ImportFrom(level=0, module="__future__"):
714+
pass
715+
case _:
716+
break
724717
pos += 1
725718
# Special case: for a decorated function, set the lineno to that of the
726719
# first decorator, not the `def`. Issue #4984.
@@ -1017,20 +1010,17 @@ def visit_BoolOp(self, boolop: ast.BoolOp) -> tuple[ast.Name, str]:
10171010
# cond is set in a prior loop iteration below
10181011
self.expl_stmts.append(ast.If(cond, fail_inner, [])) # noqa: F821
10191012
self.expl_stmts = fail_inner
1020-
# Check if the left operand is a ast.NamedExpr and the value has already been visited
1021-
if (
1022-
isinstance(v, ast.Compare)
1023-
and isinstance(v.left, ast.NamedExpr)
1024-
and v.left.target.id
1025-
in [
1026-
ast_expr.id
1027-
for ast_expr in boolop.values[:i]
1028-
if hasattr(ast_expr, "id")
1029-
]
1030-
):
1031-
pytest_temp = self.variable()
1032-
self.variables_overwrite[self.scope][v.left.target.id] = v.left # type:ignore[assignment]
1033-
v.left.target.id = pytest_temp
1013+
match v:
1014+
# Check if the left operand is an ast.NamedExpr and the value has already been visited
1015+
case ast.Compare(
1016+
left=ast.NamedExpr(target=ast.Name(id=target_id))
1017+
) if target_id in [
1018+
e.id for e in boolop.values[:i] if hasattr(e, "id")
1019+
]:
1020+
pytest_temp = self.variable()
1021+
self.variables_overwrite[self.scope][target_id] = v.left # type:ignore[assignment]
1022+
# mypy's false positive, we're checking that the 'target' attribute exists.
1023+
v.left.target.id = pytest_temp # type:ignore[attr-defined]
10341024
self.push_format_context()
10351025
res, expl = self.visit(v)
10361026
body.append(ast.Assign([ast.Name(res_var, ast.Store())], res))
@@ -1080,10 +1070,11 @@ def visit_Call(self, call: ast.Call) -> tuple[ast.Name, str]:
10801070
arg_expls.append(expl)
10811071
new_args.append(res)
10821072
for keyword in call.keywords:
1083-
if isinstance(
1084-
keyword.value, ast.Name
1085-
) and keyword.value.id in self.variables_overwrite.get(self.scope, {}):
1086-
keyword.value = self.variables_overwrite[self.scope][keyword.value.id] # type:ignore[assignment]
1073+
match keyword.value:
1074+
case ast.Name(id=id) if id in self.variables_overwrite.get(
1075+
self.scope, {}
1076+
):
1077+
keyword.value = self.variables_overwrite[self.scope][id] # type:ignore[assignment]
10871078
res, expl = self.visit(keyword.value)
10881079
new_kwargs.append(ast.keyword(keyword.arg, res))
10891080
if keyword.arg:
@@ -1119,12 +1110,13 @@ def visit_Attribute(self, attr: ast.Attribute) -> tuple[ast.Name, str]:
11191110
def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
11201111
self.push_format_context()
11211112
# We first check if we have overwritten a variable in the previous assert
1122-
if isinstance(
1123-
comp.left, ast.Name
1124-
) and comp.left.id in self.variables_overwrite.get(self.scope, {}):
1125-
comp.left = self.variables_overwrite[self.scope][comp.left.id] # type:ignore[assignment]
1126-
if isinstance(comp.left, ast.NamedExpr):
1127-
self.variables_overwrite[self.scope][comp.left.target.id] = comp.left # type:ignore[assignment]
1113+
match comp.left:
1114+
case ast.Name(id=name_id) if name_id in self.variables_overwrite.get(
1115+
self.scope, {}
1116+
):
1117+
comp.left = self.variables_overwrite[self.scope][name_id] # type: ignore[assignment]
1118+
case ast.NamedExpr(target=ast.Name(id=target_id)):
1119+
self.variables_overwrite[self.scope][target_id] = comp.left # type: ignore[assignment]
11281120
left_res, left_expl = self.visit(comp.left)
11291121
if isinstance(comp.left, ast.Compare | ast.BoolOp):
11301122
left_expl = f"({left_expl})"
@@ -1136,13 +1128,14 @@ def visit_Compare(self, comp: ast.Compare) -> tuple[ast.expr, str]:
11361128
syms: list[ast.expr] = []
11371129
results = [left_res]
11381130
for i, op, next_operand in it:
1139-
if (
1140-
isinstance(next_operand, ast.NamedExpr)
1141-
and isinstance(left_res, ast.Name)
1142-
and next_operand.target.id == left_res.id
1143-
):
1144-
next_operand.target.id = self.variable()
1145-
self.variables_overwrite[self.scope][left_res.id] = next_operand # type:ignore[assignment]
1131+
match (next_operand, left_res):
1132+
case (
1133+
ast.NamedExpr(target=ast.Name(id=target_id)),
1134+
ast.Name(id=name_id),
1135+
) if target_id == name_id:
1136+
next_operand.target.id = self.variable()
1137+
self.variables_overwrite[self.scope][name_id] = next_operand # type: ignore[assignment]
1138+
11461139
next_res, next_expl = self.visit(next_operand)
11471140
if isinstance(next_operand, ast.Compare | ast.BoolOp):
11481141
next_expl = f"({next_expl})"

src/_pytest/main.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ def pytest_addoption(parser: Parser) -> None:
9090
action="store_true",
9191
help="(Deprecated) alias to --strict-markers",
9292
)
93+
group.addoption(
94+
"--require-unique-paramset-ids",
95+
action="store_true",
96+
default=False,
97+
help="Causes pytest to raise an exception upon detection of non-unique parameter set IDs,"
98+
"rather than attempting to generate them automatically.",
99+
)
93100

94101
group = parser.getgroup("pytest-warnings")
95102
group.addoption(

src/_pytest/python.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
from _pytest.config import Config
5151
from _pytest.config import hookimpl
5252
from _pytest.config.argparsing import Parser
53+
from _pytest.config.exceptions import UsageError
5354
from _pytest.deprecated import check_ispytest
5455
from _pytest.fixtures import FixtureDef
5556
from _pytest.fixtures import FixtureRequest
@@ -902,6 +903,25 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
902903
resolved_ids = list(self._resolve_ids())
903904
# All IDs must be unique!
904905
if len(resolved_ids) != len(set(resolved_ids)):
906+
if self.config and self.config.cache._config.getoption(
907+
"require_unique_paramset_ids"
908+
):
909+
duplicate_indexs = defaultdict(list)
910+
for i, val in enumerate(resolved_ids):
911+
duplicate_indexs[val].append(i)
912+
913+
# Keep only duplicates
914+
duplicates = {k: v for k, v in duplicate_indexs.items() if len(v) > 1}
915+
raise UsageError(f"""
916+
Because: require_unique_parameterset_ids is set, pytest won't
917+
attempt to generate unique IDs for parameter sets.
918+
argument values: {self.parametersets}
919+
argument names: {self.argnames}
920+
function name: {self.func_name}
921+
test name: {self.nodeid}
922+
resolved (with non-unique) IDs: {resolved_ids}
923+
duplicates: {duplicates}
924+
""")
905925
# Record the number of occurrences of each ID.
906926
id_counts = Counter(resolved_ids)
907927
# Map the ID to its next suffix.

testing/test_assertrewrite.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1552,7 +1552,9 @@ def test_simple_failure():
15521552
result.stdout.fnmatch_lines(["*E*assert (1 + 1) == 3"])
15531553

15541554

1555-
class TestIssue10743:
1555+
class TestAssertionRewriteWalrusOperator:
1556+
"""See #10743"""
1557+
15561558
def test_assertion_walrus_operator(self, pytester: Pytester) -> None:
15571559
pytester.makepyfile(
15581560
"""
@@ -1719,6 +1721,22 @@ def test_walrus_operator_not_override_value():
17191721
result = pytester.runpytest()
17201722
assert result.ret == 0
17211723

1724+
def test_assertion_namedexpr_compare_left_overwrite(
1725+
self, pytester: Pytester
1726+
) -> None:
1727+
pytester.makepyfile(
1728+
"""
1729+
def test_namedexpr_compare_left_overwrite():
1730+
a = "Hello"
1731+
b = "World"
1732+
c = "Test"
1733+
assert (a := b) == c and (a := "Test") == "Test"
1734+
"""
1735+
)
1736+
result = pytester.runpytest()
1737+
assert result.ret == 1
1738+
result.stdout.fnmatch_lines(["*assert ('World' == 'Test'*"])
1739+
17221740

17231741
class TestIssue11028:
17241742
def test_assertion_walrus_operator_in_operand(self, pytester: Pytester) -> None:

testing/test_collection.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2031,3 +2031,36 @@ def test_namespace_packages(pytester: Pytester, import_mode: str):
20312031
" <Function test_module3>",
20322032
]
20332033
)
2034+
2035+
2036+
@pytest.mark.parametrize(
2037+
"parametrize_args, expected_indexs",
2038+
[
2039+
("[(1, 1), (1, 1)]", "{'1-1': [[]0, 1[]]}"),
2040+
("[(1, 1), (1, 2), (1, 1)]", "{'1-1': [[]0, 2[]]}"),
2041+
("[(1, 1), (2, 2), (1, 1)]", "{'1-1': [[]0, 2[]]}"),
2042+
("[(1, 1), (2, 2), (1, 2), (2, 1), (1, 1)]", "{'1-1': [[]0, 4[]]}"),
2043+
],
2044+
)
2045+
def test_option_parametrize_require_unique_paramset_ids(
2046+
pytester: Pytester, parametrize_args, expected_indexs
2047+
) -> None:
2048+
pytester.makepyfile(
2049+
f"""
2050+
import pytest
2051+
@pytest.mark.parametrize('y, x', {parametrize_args})
2052+
def test1(y, x):
2053+
pass
2054+
"""
2055+
)
2056+
result = pytester.runpytest("--require-unique-paramset-ids")
2057+
result.stdout.fnmatch_lines(
2058+
[
2059+
"E*Because: require_unique_parameterset_ids is set, pytest won't",
2060+
"E*attempt to generate unique IDs for parameter sets.",
2061+
"E*argument names: [[]'y', 'x'[]]",
2062+
"E*function name: test1",
2063+
"E*test name: test_option_parametrize_require_unique_paramset_ids.py::test1",
2064+
f"E*duplicates: {expected_indexs!s}",
2065+
]
2066+
)

0 commit comments

Comments
 (0)