Skip to content

Commit 92bd53c

Browse files
sonar-nigel[bot]Vibe Bot
authored andcommitted
SONARPY-3984 Fix S3626 false positive for bare return in marimo notebook cells (#1023)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com> GitOrigin-RevId: ac09baa64a490f05145a060ebca1d205ca8ef455
1 parent 65840c9 commit 92bd53c

File tree

7 files changed

+237
-4
lines changed

7 files changed

+237
-4
lines changed

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import org.sonar.plugins.python.api.tree.Tree.Kind;
3232
import org.sonar.python.cfg.PythonCfgBranchingBlock;
3333
import org.sonar.plugins.python.api.quickfix.PythonQuickFix;
34+
import org.sonar.python.checks.utils.MarimoUtils;
3435
import org.sonar.python.quickfix.TextEditUtils;
3536
import org.sonar.python.tree.TreeUtils;
3637

@@ -54,7 +55,7 @@ private static void checkCfg(@Nullable ControlFlowGraph cfg, SubscriptionContext
5455
.forEach(cfgBlock -> {
5556
List<Tree> elements = cfgBlock.elements();
5657
Tree lastElement = elements.get(elements.size() - 1);
57-
if (!isException(lastElement)) {
58+
if (!isException(lastElement, ctx)) {
5859
var issue = ctx.addIssue(lastElement, message(lastElement));
5960
addQuickFix(lastElement, issue);
6061

@@ -92,11 +93,12 @@ private static boolean isReturnWithExpression(Tree lastElement) {
9293
return lastElement.is(Kind.RETURN_STMT) && !((ReturnStatement) lastElement).expressions().isEmpty();
9394
}
9495

95-
private static boolean isException(Tree lastElement) {
96+
private static boolean isException(Tree lastElement, SubscriptionContext ctx) {
9697
return lastElement.is(Kind.RAISE_STMT)
9798
|| isReturnWithExpression(lastElement)
9899
|| isInsideSingleStatementBlock(lastElement)
99-
|| hasTryAncestor(lastElement);
100+
|| hasTryAncestor(lastElement)
101+
|| MarimoUtils.isTreeInMarimoDecoratedFunction(lastElement, ctx);
100102
}
101103

102104
// ignore jumps in try statement because CFG is not precise
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
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.utils;
18+
19+
import org.sonar.plugins.python.api.SubscriptionContext;
20+
import org.sonar.plugins.python.api.tree.CallExpression;
21+
import org.sonar.plugins.python.api.tree.Decorator;
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.Tree;
25+
import org.sonar.plugins.python.api.tree.Tree.Kind;
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+
public class MarimoUtils {
31+
32+
private static final TypeMatcher APP_CELL_MATCHER = TypeMatchers.isType("marimo.App.cell");
33+
private static final TypeMatcher APP_FUNCTION_MATCHER = TypeMatchers.isType("marimo.App.function");
34+
35+
private MarimoUtils() {
36+
}
37+
38+
public static boolean isTreeInMarimoDecoratedFunction(Tree tree, SubscriptionContext ctx) {
39+
FunctionDef functionDef = (FunctionDef) TreeUtils.firstAncestorOfKind(tree, Kind.FUNCDEF);
40+
if (functionDef == null) {
41+
return false;
42+
}
43+
return functionDef.decorators().stream().anyMatch(decorator -> isAppCellDecorator(decorator, ctx));
44+
}
45+
46+
private static boolean isAppCellDecorator(Decorator decorator, SubscriptionContext ctx) {
47+
Expression expr = decorator.expression();
48+
if (expr instanceof CallExpression callExpr) {
49+
expr = callExpr.callee();
50+
}
51+
return APP_CELL_MATCHER.isTrueFor(expr, ctx) || APP_FUNCTION_MATCHER.isTrueFor(expr, ctx);
52+
}
53+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource SA.
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.utils;
18+
19+
import com.sonar.sslr.api.AstNode;
20+
import org.junit.jupiter.api.Test;
21+
import org.mockito.Mockito;
22+
import org.sonar.plugins.python.api.SubscriptionContext;
23+
import org.sonar.plugins.python.api.tree.FileInput;
24+
import org.sonar.plugins.python.api.tree.FunctionDef;
25+
import org.sonar.python.TestPythonVisitorRunner;
26+
import org.sonar.python.parser.PythonParser;
27+
import org.sonar.python.semantic.ProjectLevelSymbolTable;
28+
import org.sonar.python.semantic.v2.SymbolTableBuilderV2;
29+
import org.sonar.python.semantic.v2.TypeInferenceV2;
30+
import org.sonar.python.semantic.v2.typetable.ProjectLevelTypeTable;
31+
import org.sonar.python.tree.PythonTreeMaker;
32+
33+
import static org.assertj.core.api.Assertions.assertThat;
34+
35+
class MarimoUtilsTest {
36+
37+
@Test
38+
void isTreeInMarimoDecoratedFunction_returnsFalseWhenNotInsideFunction() {
39+
FileInput fileInput = parse("""
40+
import marimo
41+
x = 1
42+
""");
43+
44+
SubscriptionContext ctx = mockSubscriptionContext();
45+
46+
assertThat(MarimoUtils.isTreeInMarimoDecoratedFunction(fileInput, ctx)).isFalse();
47+
}
48+
49+
@Test
50+
void isTreeInMarimoDecoratedFunction_returnsTrueWhenInsideAppCellFunction() {
51+
String content = """
52+
import marimo
53+
app = marimo.App()
54+
@app.cell
55+
def some_cell():
56+
return 1
57+
""";
58+
59+
FileInput fileInput = parse(content);
60+
var typeTable = new ProjectLevelTypeTable(ProjectLevelSymbolTable.empty());
61+
var mockFile = new TestPythonVisitorRunner.MockPythonFile("", "test.py", content);
62+
var symbolTableV2 = new SymbolTableBuilderV2(fileInput).build();
63+
new TypeInferenceV2(typeTable, mockFile, symbolTableV2, "").inferModuleType(fileInput);
64+
65+
SubscriptionContext ctx = Mockito.mock(SubscriptionContext.class);
66+
Mockito.when(ctx.typeTable()).thenReturn(typeTable);
67+
68+
var statements = fileInput.statements().statements();
69+
var functionDef = (FunctionDef) statements.get(statements.size() - 1);
70+
var returnStatement = functionDef.body().statements().get(0);
71+
72+
assertThat(MarimoUtils.isTreeInMarimoDecoratedFunction(returnStatement, ctx)).isTrue();
73+
}
74+
75+
private static SubscriptionContext mockSubscriptionContext() {
76+
SubscriptionContext ctx = Mockito.mock(SubscriptionContext.class);
77+
Mockito.when(ctx.typeTable()).thenReturn(new ProjectLevelTypeTable(ProjectLevelSymbolTable.empty()));
78+
return ctx;
79+
}
80+
81+
private static FileInput parse(String content) {
82+
PythonParser parser = PythonParser.create();
83+
AstNode astNode = parser.parse(content);
84+
return new PythonTreeMaker().fileInput(astNode);
85+
}
86+
}

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,3 +340,47 @@ def match_statement_single_return(value):
340340
match value:
341341
case "42":
342342
return # OK
343+
344+
345+
import marimo
346+
347+
app = marimo.App()
348+
349+
350+
@app.cell
351+
def _(mo, engine):
352+
_df = mo.sql(f"""SELECT * FROM table""", engine=engine)
353+
return # OK
354+
355+
356+
@app.cell(hide_code=True)
357+
def _(mo):
358+
mo.md("# Dashboard")
359+
return # OK
360+
361+
362+
@app.cell
363+
def _(mo, condition):
364+
if condition:
365+
mo.md("Condition met")
366+
return # OK
367+
mo.md("Condition not met")
368+
return # OK
369+
370+
371+
@app.function
372+
def helper():
373+
x = 1
374+
return # OK
375+
376+
377+
@app.function
378+
def helper_with_condition(condition):
379+
if condition:
380+
return # OK
381+
return # OK
382+
383+
384+
def not_a_marimo_cell():
385+
x = 1
386+
return # Noncompliant

python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/marimo.protobuf

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
2+
marimo�
3+
App
4+
marimo.App"*SonarPythonAnalyzerFakeStub.CustomStubBase*u
5+
__init__marimo.App.__init__"
6+
None*"
7+
self
8+
9+
marimo.App"
10+
marimo.App*
11+
args
12+
Any*
13+
kwargs
14+
Any*l
15+
cellmarimo.App.cell"
16+
Any*"
17+
self
18+
19+
marimo.App"
20+
marimo.App*
21+
args
22+
Any*
23+
kwargs
24+
Any*t
25+
functionmarimo.App.function"
26+
Any*"
27+
self
28+
29+
marimo.App"
30+
marimo.App*
31+
args
32+
Any*
33+
kwargs
34+
Any*g
35+
__path__marimo.__path__J
36+
builtins.list[builtins.str]
37+
builtins.str" builtins.str"builtins.list*�
38+
__annotations__marimo.__annotations__W
39+
builtins.dict[builtins.str,Any]
40+
builtins.str" builtins.str
41+
Any"builtins.dict
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from SonarPythonAnalyzerFakeStub import CustomStubBase
2+
from typing import Any
3+
4+
class App(CustomStubBase):
5+
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
6+
def cell(self, *args: Any, **kwargs: Any) -> Any: ...
7+
def function(self, *args: Any, **kwargs: Any) -> Any: ...

python-frontend/typeshed_serializer/tests/test_serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def test_custom_stubs_serializer(typeshed_custom_stubs):
7474
custom_stubs_serializer.serialize()
7575
assert custom_stubs_serializer.get_build_result.call_count == 1
7676
# Not every files from "typeshed_custom_stubs" build are serialized, as some are builtins
77-
assert symbols.save_module.call_count == 361
77+
assert symbols.save_module.call_count == 362
7878

7979

8080
def test_importer_serializer():

0 commit comments

Comments
 (0)