Skip to content

Commit 322e2dc

Browse files
rombirlisonartech
authored andcommitted
SONARPY-4434 Add S9116: Pytest fixture options should be passed as keyword arguments
GitOrigin-RevId: 6282221ce406e55bd91e4b6f972245f0891ffaaa
1 parent 6d50d03 commit 322e2dc

7 files changed

Lines changed: 282 additions & 1 deletion

File tree

python-checks/src/main/java/org/sonar/python/checks/OpenSourceCheckList.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
import org.sonar.python.checks.tests.NotDiscoverableTestMethodCheck;
109109
import org.sonar.python.checks.tests.PytestAutouseParametrizedFixtureCheck;
110110
import org.sonar.python.checks.tests.PytestFixtureMultipleYieldCheck;
111+
import org.sonar.python.checks.tests.PytestFixturePositionalArgsCheck;
111112
import org.sonar.python.checks.tests.PytestPluginsConftestCheck;
112113
import org.sonar.python.checks.tests.PytestRaisesContextManagerCheck;
113114
import org.sonar.python.checks.tests.SingleInvocationRuntimeExceptionCheck;
@@ -412,6 +413,7 @@ public Stream<Class<?>> getChecks() {
412413
PydanticSkipValidationWithConstraintsCheck.class,
413414
PytestAutouseParametrizedFixtureCheck.class,
414415
PytestFixtureMultipleYieldCheck.class,
416+
PytestFixturePositionalArgsCheck.class,
415417
PytestPluginsConftestCheck.class,
416418
PytestRaisesContextManagerCheck.class,
417419
PyTorchDataLoaderNumWorkersCheck.class,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* You can redistribute and/or modify this program under the terms of
7+
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks.tests;
18+
19+
import org.sonar.check.Rule;
20+
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
21+
import org.sonar.plugins.python.api.SubscriptionContext;
22+
import org.sonar.plugins.python.api.tree.Argument;
23+
import org.sonar.plugins.python.api.tree.CallExpression;
24+
import org.sonar.plugins.python.api.tree.Decorator;
25+
import org.sonar.plugins.python.api.tree.RegularArgument;
26+
import org.sonar.plugins.python.api.tree.Tree;
27+
import org.sonar.plugins.python.api.tree.UnpackingExpression;
28+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
29+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
30+
31+
@Rule(key = "S9116")
32+
public class PytestFixturePositionalArgsCheck extends PythonSubscriptionCheck {
33+
34+
private static final String MESSAGE = "Pass fixture options as keyword arguments.";
35+
36+
private static final TypeMatcher PYTEST_FIXTURE_MATCHER = TypeMatchers.withFQN("pytest.fixture");
37+
38+
@Override
39+
public void initialize(Context context) {
40+
context.registerSyntaxNodeConsumer(Tree.Kind.DECORATOR, PytestFixturePositionalArgsCheck::checkDecorator);
41+
}
42+
43+
private static void checkDecorator(SubscriptionContext ctx) {
44+
Decorator decorator = (Decorator) ctx.syntaxNode();
45+
if (!(decorator.expression() instanceof CallExpression callExpression)) {
46+
return;
47+
}
48+
if (!PYTEST_FIXTURE_MATCHER.isTrueFor(callExpression.callee(), ctx)) {
49+
return;
50+
}
51+
52+
for (Argument argument : callExpression.arguments()) {
53+
if (isPositionalArgument(argument)) {
54+
ctx.addIssue(argument, MESSAGE);
55+
return;
56+
}
57+
}
58+
}
59+
60+
private static boolean isPositionalArgument(Argument argument) {
61+
if (argument instanceof RegularArgument regularArgument) {
62+
return regularArgument.keywordArgument() == null;
63+
}
64+
if (argument instanceof UnpackingExpression unpackingExpression) {
65+
return "*".equals(unpackingExpression.starToken().value());
66+
}
67+
return false;
68+
}
69+
70+
@Override
71+
public CheckScope scope() {
72+
return CheckScope.ALL;
73+
}
74+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<p>This rule raises an issue when <code>@pytest.fixture</code> options are passed as positional arguments.</p>
2+
<p>In Python, this refers to <code>@pytest.fixture(…​)</code> with options passed as positional arguments, such as
3+
<code>@pytest.fixture("module")</code>.</p>
4+
<h2>Why is this an issue?</h2>
5+
<p>Passing options like scope as positional arguments to <code>@pytest.fixture</code> is ambiguous and has been removed in pytest 6.0 and later. Use
6+
keyword arguments:</p>
7+
<pre>
8+
@pytest.fixture("module") # Removed; use keyword arguments
9+
def shared_state():
10+
return {}
11+
</pre>
12+
<p>Prefer:</p>
13+
<pre>
14+
@pytest.fixture(scope="module")
15+
def shared_state():
16+
return {}
17+
</pre>
18+
<h3>What is the potential impact?</h3>
19+
<p>Positional arguments to <code>@pytest.fixture</code> are rejected by modern pytest, so fixtures written this way fail to load or raise errors at
20+
collection time.</p>
21+
<h2>How to fix it</h2>
22+
<p>Pass fixture options with keywords (for example <code>scope="module"</code>).</p>
23+
<h3>Code examples</h3>
24+
<h4>Noncompliant code example</h4>
25+
<pre data-diff-id="1" data-diff-type="noncompliant">
26+
import pytest
27+
28+
@pytest.fixture("module") # Noncompliant
29+
def scoped():
30+
return []
31+
</pre>
32+
<h4>Compliant solution</h4>
33+
<pre data-diff-id="1" data-diff-type="compliant">
34+
import pytest
35+
36+
@pytest.fixture(scope="module")
37+
def scoped():
38+
return []
39+
</pre>
40+
<h2>Resources</h2>
41+
<h3>Documentation</h3>
42+
<ul>
43+
<li>pytest documentation - <a href="https://docs.pytest.org/en/stable/reference/reference.html#pytest.fixture">pytest.fixture</a></li>
44+
<li>pytest documentation - <a href="https://docs.pytest.org/en/stable/deprecations.html#pytest-fixture-arguments-are-keyword-only">pytest.fixture
45+
arguments are keyword only</a></li>
46+
<li>Ruff - <a href="https://docs.astral.sh/ruff/rules/pytest-fixture-positional-args/">PT002 — pytest-fixture-positional-args</a></li>
47+
</ul>
48+
<h3>Related rules</h3>
49+
<ul>
50+
<li>{rule:python:S9076} - Deprecated pytest.yield_fixture should be replaced with pytest.fixture</li>
51+
<li>{rule:python:S9117} - Pytest fixtures should not declare the default scope="function"</li>
52+
</ul>
53+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"title": "Pytest fixture options should be passed as keyword arguments",
3+
"type": "CODE_SMELL",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5min"
8+
},
9+
"tags": [
10+
"pytest",
11+
"tests"
12+
],
13+
"defaultSeverity": "Major",
14+
"ruleSpecification": "RSPEC-9116",
15+
"sqKey": "S9116",
16+
"scope": "Tests",
17+
"quickfix": "unknown",
18+
"code": {
19+
"impacts": {
20+
"MAINTAINABILITY": "MEDIUM"
21+
},
22+
"attribute": "CONVENTIONAL"
23+
}
24+
}

python-checks/src/main/resources/org/sonar/l10n/py/rules/python/Sonar_way_profile.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,7 @@
385385
"S9074",
386386
"S9075",
387387
"S9076",
388-
"S9078"
388+
"S9078",
389+
"S9116"
389390
]
390391
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* You can redistribute and/or modify this program under the terms of
7+
* the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonar.python.checks.tests;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.plugins.python.api.PythonCheck;
21+
import org.sonar.python.checks.utils.PythonCheckVerifier;
22+
23+
import static org.assertj.core.api.Assertions.assertThat;
24+
25+
class PytestFixturePositionalArgsCheckTest {
26+
27+
@Test
28+
void test() {
29+
PythonCheckVerifier.verify("src/test/resources/checks/tests/pytestFixturePositionalArgs.py", new PytestFixturePositionalArgsCheck());
30+
}
31+
32+
@Test
33+
void test_scope() {
34+
assertThat(new PytestFixturePositionalArgsCheck().scope()).isEqualTo(PythonCheck.CheckScope.ALL);
35+
}
36+
37+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import pytest
2+
from pytest import fixture
3+
4+
5+
@pytest.fixture("module") # Noncompliant {{Pass fixture options as keyword arguments.}}
6+
# ^^^^^^^^
7+
def noncompliant_positional_scope():
8+
return []
9+
10+
11+
@pytest.fixture(True) # Noncompliant
12+
# ^^^^
13+
def noncompliant_positional_autouse():
14+
return []
15+
16+
17+
@pytest.fixture("module", True) # Noncompliant
18+
# ^^^^^^^^
19+
def noncompliant_multiple_positionals():
20+
return []
21+
22+
23+
@pytest.fixture("module", autouse=True) # Noncompliant
24+
# ^^^^^^^^
25+
def noncompliant_mixed_positional_and_keyword():
26+
return []
27+
28+
29+
OPTS = ("module",)
30+
@pytest.fixture(*OPTS) # Noncompliant
31+
# ^^^^^
32+
def noncompliant_unpacked_positional():
33+
return []
34+
35+
36+
@fixture("session") # Noncompliant
37+
# ^^^^^^^^^
38+
def noncompliant_imported_fixture():
39+
return []
40+
41+
42+
class TestSomething:
43+
@pytest.fixture("class") # Noncompliant
44+
# ^^^^^^^
45+
def noncompliant_class_fixture(self):
46+
return []
47+
48+
49+
@pytest.fixture(scope="module")
50+
def compliant_keyword_scope():
51+
return []
52+
53+
54+
@pytest.fixture(autouse=True)
55+
def compliant_keyword_autouse():
56+
return []
57+
58+
59+
@pytest.fixture(scope="module", autouse=True)
60+
def compliant_multiple_keywords():
61+
return []
62+
63+
64+
@pytest.fixture()
65+
def compliant_empty_call():
66+
return []
67+
68+
69+
@pytest.fixture
70+
def compliant_no_parentheses():
71+
return []
72+
73+
74+
@pytest.fixture(**{"scope": "module"})
75+
def compliant_unpacked_keywords():
76+
return []
77+
78+
79+
@fixture(scope="function")
80+
def compliant_imported_fixture():
81+
return []
82+
83+
84+
def not_a_fixture(*args):
85+
pass
86+
87+
88+
@not_a_fixture("module")
89+
def decorated_with_unrelated_call():
90+
pass

0 commit comments

Comments
 (0)