Skip to content

Commit b44444d

Browse files
Seppli11sonartech
authored andcommitted
SONARPY-3710 S8515: use call graph
GitOrigin-RevId: bf2c593caee0a4ade07614a85ea7c9a9e2600f30
1 parent d54052a commit b44444d

2 files changed

Lines changed: 48 additions & 27 deletions

File tree

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

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@
3838
import org.sonar.plugins.python.api.tree.NumericLiteral;
3939
import org.sonar.plugins.python.api.tree.RaiseStatement;
4040
import org.sonar.plugins.python.api.tree.RegularArgument;
41-
import org.sonar.plugins.python.api.tree.StatementList;
4241
import org.sonar.plugins.python.api.tree.StringLiteral;
4342
import org.sonar.plugins.python.api.tree.Tree;
43+
import org.sonar.plugins.python.api.types.v2.FullyQualifiedNameHelper;
4444
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
4545
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
4646
import org.sonar.python.checks.utils.Expressions;
47+
import org.sonar.python.semantic.v2.callgraph.CallGraph;
4748
import org.sonar.python.tree.TreeUtils;
4849

4950
@Rule(key = "S8415")
@@ -65,14 +66,21 @@ public class FastAPIHTTPExceptionDocumentedCheck extends PythonSubscriptionCheck
6566
TypeMatchers.isType("fastapi.exceptions.HTTPException"),
6667
TypeMatchers.isType("fastapi.HTTPException"));
6768

68-
private static final int MAX_RECURSION_DEPTH = 5;
69+
private static final int MAX_FUNCTION_CALLS = 100;
70+
71+
private final Set<Expression> reportedHttpExceptionCalls = new HashSet<>();
6972

7073
@Override
7174
public void initialize(Context context) {
72-
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, FastAPIHTTPExceptionDocumentedCheck::checkFunctionDef);
75+
context.registerSyntaxNodeConsumer(Tree.Kind.FILE_INPUT, this::init);
76+
context.registerSyntaxNodeConsumer(Tree.Kind.FUNCDEF, this::checkFunctionDef);
77+
}
78+
79+
private void init(SubscriptionContext ctx) {
80+
reportedHttpExceptionCalls.clear();
7381
}
7482

75-
private static void checkFunctionDef(SubscriptionContext ctx) {
83+
private void checkFunctionDef(SubscriptionContext ctx) {
7684
FunctionDef functionDef = (FunctionDef) ctx.syntaxNode();
7785

7886
DecoratorAnalysisResult analysisResult = new DecoratorAnalysis(ctx, functionDef).analyze();
@@ -146,16 +154,16 @@ private static Set<Integer> extractDocumentedStatusCodes(Expression responsesExp
146154

147155
return statusCodes;
148156
}
149-
150157
}
151158

152-
private static void reportUndocumentedExceptions(
159+
private void reportUndocumentedExceptions(
153160
SubscriptionContext ctx,
154161
List<RaiseInfo> httpExceptions,
155162
Set<Integer> documentedStatusCodes) {
156163
for (RaiseInfo raiseInfo : httpExceptions) {
157-
if (!documentedStatusCodes.contains(raiseInfo.statusCode)) {
164+
if (!documentedStatusCodes.contains(raiseInfo.statusCode) && !reportedHttpExceptionCalls.contains(raiseInfo.httpExceptionExpression)) {
158165
ctx.addIssue(raiseInfo.httpExceptionExpression, String.format(MESSAGE, raiseInfo.statusCode));
166+
reportedHttpExceptionCalls.add(raiseInfo.httpExceptionExpression);
159167
}
160168
}
161169
}
@@ -169,33 +177,27 @@ private record DecoratorAnalysisResult(
169177
private static class RaiseInfoCollector {
170178
private final SubscriptionContext ctx;
171179
private final FunctionDef functionDef;
172-
private final Set<FunctionDef> visited = new HashSet<>();
173180

174181
RaiseInfoCollector(SubscriptionContext ctx, FunctionDef functionDef) {
175182
this.ctx = ctx;
176183
this.functionDef = functionDef;
177184
}
178185

179186
public List<RaiseInfo> collect() {
180-
return collect(functionDef, 0);
181-
}
182-
183-
private List<RaiseInfo> collect(FunctionDef functionDef, int depth) {
184-
List<RaiseInfo> result = new ArrayList<>();
187+
List<RaiseInfo> result = new ArrayList<>(HTTPExceptionVisitor.collect(ctx, functionDef));
185188

186-
if (visited.contains(functionDef) || depth > MAX_RECURSION_DEPTH) {
189+
String fqn = FullyQualifiedNameHelper.getFullyQualifiedName(functionDef.name().typeV2()).orElse(null);
190+
if (fqn == null) {
187191
return result;
188192
}
189-
visited.add(functionDef);
190193

191-
StatementList body = functionDef.body();
192-
if (body == null) {
193-
return result;
194-
}
194+
CallGraph callGraph = ctx.callGraph();
195195

196-
HTTPExceptionVisitor visitor = new HTTPExceptionVisitor(ctx);
197-
body.accept(visitor);
198-
result.addAll(visitor.httpExceptions);
196+
callGraph.forwardStream(fqn)
197+
.limit(MAX_FUNCTION_CALLS)
198+
.forEach(node -> node.tree()
199+
.flatMap(TreeUtils.toOptionalInstanceOfMapper(FunctionDef.class))
200+
.ifPresent(calledFunction -> result.addAll(HTTPExceptionVisitor.collect(ctx, calledFunction))));
199201

200202
return result;
201203
}
@@ -244,6 +246,12 @@ public void visitFunctionDef(FunctionDef pyFunctionDefTree) {
244246
public void visitLambda(LambdaExpression pyLambdaExpressionTree) {
245247
// don't decend into nested lambdas
246248
}
249+
250+
public static List<RaiseInfo> collect(SubscriptionContext ctx, FunctionDef tree) {
251+
HTTPExceptionVisitor visitor = new HTTPExceptionVisitor(ctx);
252+
tree.body().accept(visitor);
253+
return visitor.httpExceptions;
254+
}
247255
}
248256

249257
private static Optional<Integer> extractStatusCode(Expression statusCodeExpr) {

python-checks/src/test/resources/checks/fastAPIHTTPExceptionDocumented.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ def api_router_instead_of_app():
9393
# Testing following calls
9494
def helper_with_exception(item_id: int):
9595
if ...:
96-
raise HTTPException(status_code=400, detail="Invalid ID") # FN
96+
raise HTTPException(status_code=400, detail="Invalid ID") # Noncompliant
9797

9898

9999
@app.get("/items/{item_id}")
@@ -102,12 +102,12 @@ def endpoint_calling_helper(item_id: int):
102102

103103

104104
def nested_helper_level_2():
105-
raise HTTPException(status_code=403, detail="Forbidden") # FN
105+
raise HTTPException(status_code=403, detail="Forbidden") # Noncompliant
106106

107107

108108
def nested_helper_level_1(id: int):
109109
if ...:
110-
raise HTTPException(status_code=400, detail="Bad ID") # FN
110+
raise HTTPException(status_code=400, detail="Bad ID") # Noncompliant
111111
nested_helper_level_2()
112112

113113

@@ -268,16 +268,28 @@ def exception_without_status_code():
268268
raise HTTPException(detail="Missing status code") # Compliant (no status_code to analyze)
269269

270270
@app.get("/no-status-code")
271-
def exception_without_status_code():
271+
def exception_with_inner_function():
272272
def inner_function():
273273
raise HTTPException(detail="Missing status code") # Compliant
274274

275275
def inner_function2():
276-
raise HTTPException(status_code=500, detail="Missing status code") # FN
276+
raise HTTPException(status_code=500, detail="Missing status code") # Noncompliant
277277

278278
inner_function2()
279279

280280

281+
@app.get("/no-status-code")
282+
def call_same_function_twice_first_call():
283+
called_function()
284+
285+
@app.get("/no-status-code")
286+
def call_same_function_twice_second_call():
287+
called_function()
288+
289+
290+
def called_function():
291+
raise HTTPException(status_code=500, detail="Missing status code") # Noncompliant
292+
281293

282294
base_responses = {500: {"description": "Server error"}}
283295
@app.get("/dict-unpacking", responses={404: {"description": "Not found"}, **base_responses})
@@ -287,3 +299,4 @@ def responses_with_dict_unpacking():
287299
# Dummy decorator for test
288300
def some_other_decorator(func):
289301
return func
302+

0 commit comments

Comments
 (0)