Skip to content

Commit 912c362

Browse files
sonar-nigel[bot]sonartech
authored andcommitted
SONARPY-3912 Rule S8493 "StopIteration" should not be raised inside generators (#957)
GitOrigin-RevId: d2513cf9afb9167f8a7d2431ed871efa9e137ca0
1 parent 9182383 commit 912c362

File tree

7 files changed

+375
-1
lines changed

7 files changed

+375
-1
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,7 @@ public Stream<Class<?>> getChecks() {
414414
SpecialMethodReturnTypeCheck.class,
415415
SQLQueriesCheck.class,
416416
StandardInputCheck.class,
417+
StopIterationInGeneratorCheck.class,
417418
StrftimeConfusingHourSystemCheck.class,
418419
StringFormatCorrectnessCheck.class,
419420
StringFormatMisuseCheck.class,
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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;
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.Expression;
23+
import org.sonar.plugins.python.api.tree.FunctionDef;
24+
import org.sonar.plugins.python.api.tree.RaiseStatement;
25+
import org.sonar.plugins.python.api.tree.Tree;
26+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
27+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
28+
import org.sonar.python.tree.TreeUtils;
29+
30+
@Rule(key = "S8493")
31+
public class StopIterationInGeneratorCheck extends PythonSubscriptionCheck {
32+
33+
private static final String MESSAGE = "Replace this \"raise StopIteration\" with a \"return\" statement.";
34+
35+
private final TypeMatcher stopIterationMatcher = TypeMatchers.any(
36+
TypeMatchers.isObjectOfType("StopIteration"),
37+
TypeMatchers.isType("StopIteration")
38+
);
39+
40+
@Override
41+
public void initialize(Context context) {
42+
context.registerSyntaxNodeConsumer(Tree.Kind.RAISE_STMT, this::checkRaise);
43+
}
44+
45+
private void checkRaise(SubscriptionContext ctx) {
46+
RaiseStatement raiseStatement = (RaiseStatement) ctx.syntaxNode();
47+
if (raiseStatement.expressions().isEmpty()) {
48+
return;
49+
}
50+
Expression expression = raiseStatement.expressions().get(0);
51+
if (!stopIterationMatcher.isTrueFor(expression, ctx)) {
52+
return;
53+
}
54+
FunctionDef enclosingFunction = (FunctionDef) TreeUtils.firstAncestorOfKind(raiseStatement, Tree.Kind.FUNCDEF);
55+
if (enclosingFunction == null) {
56+
return;
57+
}
58+
if (!ReturnCheckUtils.ReturnStmtCollector.collect(enclosingFunction).containsYield()) {
59+
return;
60+
}
61+
ctx.addIssue(raiseStatement, MESSAGE);
62+
}
63+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<p>This rule raises an issue when <code>StopIteration</code> is explicitly raised within a generator function, that is, a function containing
2+
<code>yield</code>.</p>
3+
<h2>Why is this an issue?</h2>
4+
<p>In Python, generators are special functions that use the <code>yield</code> keyword to produce a sequence of values lazily. They implement the
5+
iterator protocol and are used to create iterators in a concise way.</p>
6+
<p>Traditionally, raising <code>StopIteration</code> was the standard way to signal that an iterator had no more values to produce. However, this
7+
could lead to subtle bugs when <code>StopIteration</code> exceptions from nested iterators or function calls accidentally propagated up and terminated
8+
the generator unexpectedly.</p>
9+
<p>To address this issue, <a href="https://peps.python.org/pep-0479/">PEP 479</a> changed the behavior in Python 3.7+. Now, when
10+
<code>StopIteration</code> is raised inside a generator function, Python automatically converts it to a <code>RuntimeError</code>. This conversion
11+
happens at the point where the exception would leave the generator.</p>
12+
<p>This means code that explicitly raises <code>StopIteration</code> in a generator will fail with a <code>RuntimeError</code> at runtime in Python
13+
3.7 and later versions. The error message will indicate that the <code>StopIteration</code> exception was raised inside a generator.</p>
14+
<p>The proper way to terminate a generator is to use a <code>return</code> statement, optionally with a value. When a generator executes a
15+
<code>return</code>, Python automatically raises <code>StopIteration</code> behind the scenes, but does so in a way that is compatible with PEP
16+
479.</p>
17+
<h3>What is the potential impact?</h3>
18+
<p>When <code>StopIteration</code> is raised inside a generator in Python 3.7+, it causes a <code>RuntimeError</code> at runtime. This can lead
19+
to:</p>
20+
<ul>
21+
<li>application crashes or unexpected failures</li>
22+
<li>difficult-to-debug errors, especially in production environments</li>
23+
<li>code that works in older Python versions but breaks when upgraded</li>
24+
<li>inconsistent behavior across different Python versions</li>
25+
</ul>
26+
<h2>How to fix it</h2>
27+
<p>Replace the <code>raise StopIteration</code> statement with a <code>return</code> statement. In generators, <code>return</code> is the proper way
28+
to signal completion. If you need to return a value when the generator completes, you can use <code>return value</code>, though note that this value
29+
is not yielded but becomes the exception value of the <code>StopIteration</code> that is automatically raised.</p>
30+
<h3>Code examples</h3>
31+
<h4>Noncompliant code example</h4>
32+
<pre data-diff-id="1" data-diff-type="noncompliant">
33+
def my_generator():
34+
yield 1
35+
yield 2
36+
raise StopIteration # Noncompliant
37+
</pre>
38+
<h4>Compliant solution</h4>
39+
<pre data-diff-id="1" data-diff-type="compliant">
40+
def my_generator():
41+
yield 1
42+
yield 2
43+
return # Properly terminates the generator
44+
</pre>
45+
<h2>Resources</h2>
46+
<h3>Documentation</h3>
47+
<ul>
48+
<li>PEP 479 - <a href="https://peps.python.org/pep-0479/">Change StopIteration handling inside generators</a></li>
49+
<li>Python Documentation - <a href="https://docs.python.org/3/howto/functional.html#generators">Generators</a></li>
50+
<li>Python Documentation - <a href="https://docs.python.org/3/library/stdtypes.html#iterator-types">Iterator Types</a></li>
51+
</ul>
52+
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"title": "\"StopIteration\" should not be raised inside generators",
3+
"type": "BUG",
4+
"status": "ready",
5+
"remediation": {
6+
"func": "Constant\/Issue",
7+
"constantCost": "5 min"
8+
},
9+
"tags": [
10+
"pitfall",
11+
"python3"
12+
],
13+
"defaultSeverity": "Blocker",
14+
"ruleSpecification": "RSPEC-8493",
15+
"sqKey": "S8493",
16+
"scope": "Main",
17+
"quickfix": "unknown",
18+
"code": {
19+
"impacts": {
20+
"RELIABILITY": "BLOCKER",
21+
"MAINTAINABILITY": "HIGH"
22+
},
23+
"attribute": "LOGICAL"
24+
}
25+
}

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
@@ -334,6 +334,7 @@
334334
"S8504",
335335
"S8517",
336336
"S8520",
337-
"S8521"
337+
"S8521",
338+
"S8493"
338339
]
339340
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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;
18+
19+
import org.junit.jupiter.api.Test;
20+
import org.sonar.python.checks.utils.PythonCheckVerifier;
21+
22+
class StopIterationInGeneratorCheckTest {
23+
24+
@Test
25+
void test() {
26+
PythonCheckVerifier.verify("src/test/resources/checks/stopIterationInGenerator.py", new StopIterationInGeneratorCheck());
27+
}
28+
29+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
def generator_basic():
2+
yield 1
3+
yield 2
4+
raise StopIteration # Noncompliant {{Replace this "raise StopIteration" with a "return" statement.}}
5+
6+
def generator_with_int_arg():
7+
yield 1
8+
raise StopIteration(42) # Noncompliant {{Replace this "raise StopIteration" with a "return" statement.}}
9+
10+
def generator_with_str_arg():
11+
yield 1
12+
raise StopIteration("message") # Noncompliant {{Replace this "raise StopIteration" with a "return" statement.}}
13+
14+
def generator_raise_in_if(condition):
15+
yield 1
16+
if condition:
17+
raise StopIteration # Noncompliant
18+
19+
def generator_raise_in_else(condition):
20+
yield 1
21+
if condition:
22+
pass
23+
else:
24+
raise StopIteration # Noncompliant
25+
26+
def generator_raise_in_for(items):
27+
for item in items:
28+
if item is None:
29+
raise StopIteration # Noncompliant
30+
yield item
31+
32+
def generator_raise_in_while():
33+
count = 0
34+
while count < 10:
35+
if count == 5:
36+
raise StopIteration # Noncompliant
37+
yield count
38+
count += 1
39+
40+
def generator_raise_in_try():
41+
yield 1
42+
try:
43+
raise StopIteration # Noncompliant
44+
except ValueError:
45+
pass
46+
47+
def generator_raise_in_except():
48+
yield 1
49+
try:
50+
pass
51+
except StopIteration:
52+
raise StopIteration # Noncompliant
53+
54+
def generator_raise_in_finally():
55+
yield 1
56+
try:
57+
pass
58+
finally:
59+
raise StopIteration # Noncompliant
60+
61+
def generator_raise_chained():
62+
yield 1
63+
raise StopIteration from ValueError("cause") # Noncompliant
64+
65+
async def async_generator_raise():
66+
yield 1
67+
raise StopIteration # Noncompliant
68+
69+
def generator_after_yield_from():
70+
yield from range(3)
71+
raise StopIteration # Noncompliant
72+
73+
def generator_nested_control_flow(data):
74+
for item in data:
75+
yield item
76+
if item > 100:
77+
if item > 1000:
78+
raise StopIteration # Noncompliant
79+
80+
81+
def generator_return():
82+
yield 1
83+
yield 2
84+
return
85+
86+
def generator_return_value():
87+
yield 1
88+
return 42
89+
90+
def generator_natural_exhaustion():
91+
yield 1
92+
yield 2
93+
94+
def regular_function():
95+
raise StopIteration
96+
97+
class ManualIterator:
98+
def __init__(self):
99+
self.count = 0
100+
101+
def __next__(self):
102+
self.count += 1
103+
if self.count > 3:
104+
raise StopIteration
105+
return self.count
106+
107+
def helper_raises():
108+
raise StopIteration
109+
110+
def generator_calls_helper():
111+
yield 1
112+
helper_raises()
113+
114+
def outer_generator_nested_helper():
115+
def inner_helper():
116+
raise StopIteration
117+
yield 1
118+
inner_helper()
119+
120+
def generator_catches_stop_iteration():
121+
yield 1
122+
try:
123+
pass
124+
except StopIteration:
125+
return
126+
127+
def generator_raises_value_error():
128+
yield 1
129+
raise ValueError("not a stop iteration")
130+
131+
def generator_bare_raise():
132+
yield 1
133+
try:
134+
pass
135+
except Exception:
136+
raise
137+
138+
raise StopIteration
139+
140+
def generator_deeply_nested_helper():
141+
def inner_non_generator():
142+
def innermost():
143+
raise StopIteration
144+
innermost()
145+
yield 1
146+
inner_non_generator()
147+
148+
async def async_generator_catches():
149+
yield 1
150+
try:
151+
pass
152+
except StopIteration:
153+
return
154+
155+
156+
class IteratorClass:
157+
def generator_method(self):
158+
yield 1
159+
raise StopIteration # Noncompliant
160+
161+
162+
def nested_generators():
163+
def inner_generator():
164+
yield 10
165+
raise StopIteration # Noncompliant
166+
yield 1
167+
raise StopIteration # Noncompliant
168+
169+
170+
class CustomStopIteration(StopIteration):
171+
pass
172+
173+
def generator_raises_stop_iteration_subclass():
174+
yield 1
175+
raise CustomStopIteration
176+
177+
178+
def generator_raises_stop_iteration_subclass_instance():
179+
yield 1
180+
raise CustomStopIteration("done")
181+
182+
183+
def generator_raise_assigned_variable():
184+
yield 1
185+
exc = StopIteration("error")
186+
raise exc # Noncompliant
187+
188+
189+
async def async_generator_yield_from():
190+
yield from range(3)
191+
raise StopIteration # Noncompliant
192+
193+
194+
def generator_yield_in_nested_loop(items, stop_early):
195+
# Real-world pattern: yield inside nested for loop, raise StopIteration in elif branch
196+
for item in items:
197+
if isinstance(item, list):
198+
for x in item:
199+
yield x
200+
elif stop_early:
201+
raise StopIteration # Noncompliant
202+
else:
203+
yield item

0 commit comments

Comments
 (0)