Skip to content

Commit f1d98b4

Browse files
sonar-nigel[bot]Vibe Bot
authored andcommitted
SONARPY-3832 Fix S905 false positive for bare Airflow operator names at module level (#985)
Co-authored-by: Vibe Bot <vibe-bot@sonarsource.com> GitOrigin-RevId: 06f6aba74ae9910bc8a5a43f6fca42451242c10c
1 parent f27a7c9 commit f1d98b4

32 files changed

+374
-40
lines changed

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

Lines changed: 44 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,23 @@
1717
package org.sonar.python.checks;
1818

1919
import java.util.Arrays;
20-
import java.util.HashSet;
2120
import java.util.List;
22-
import java.util.Objects;
2321
import java.util.Optional;
24-
import java.util.Set;
2522
import java.util.stream.Stream;
2623
import org.sonar.check.Rule;
2724
import org.sonar.check.RuleProperty;
2825
import org.sonar.plugins.python.api.PythonSubscriptionCheck;
2926
import org.sonar.plugins.python.api.SubscriptionContext;
30-
import org.sonar.plugins.python.api.symbols.ClassSymbol;
31-
import org.sonar.plugins.python.api.symbols.FunctionSymbol;
32-
import org.sonar.plugins.python.api.symbols.Symbol;
33-
import org.sonar.plugins.python.api.symbols.Usage;
27+
import org.sonar.plugins.python.api.symbols.v2.SymbolV2;
28+
import org.sonar.plugins.python.api.symbols.v2.UsageV2;
29+
import org.sonar.plugins.python.api.types.v2.FunctionType;
30+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatcher;
31+
import org.sonar.plugins.python.api.types.v2.matchers.TypeMatchers;
3432
import org.sonar.plugins.python.api.tree.BinaryExpression;
3533
import org.sonar.plugins.python.api.tree.CallExpression;
3634
import org.sonar.plugins.python.api.tree.ClassDef;
3735
import org.sonar.plugins.python.api.tree.ConditionalExpression;
36+
import org.sonar.plugins.python.api.tree.Decorator;
3837
import org.sonar.plugins.python.api.tree.Expression;
3938
import org.sonar.plugins.python.api.tree.FileInput;
4039
import org.sonar.plugins.python.api.tree.FunctionDef;
@@ -87,10 +86,22 @@ private List<String> ignoredOperators() {
8786

8887
private static final List<Kind> unaryExpressionKinds = Arrays.asList(Kind.UNARY_PLUS, Kind.UNARY_MINUS, Kind.BITWISE_COMPLEMENT, Kind.NOT);
8988

90-
private static final Set<String> ignoredContexts = new HashSet<>(List.of("contextlib.suppress"));
91-
9289
private static final String MESSAGE = "Remove or refactor this statement; it has no side effects.";
9390

91+
private static final TypeMatcher EXCEPTION_CLASS_TYPE_MATCHER = TypeMatchers.isOrExtendsType("builtins.BaseException");
92+
93+
private static final TypeMatcher CONTEXTLIB_SUPPRESS_TYPE_MATCHER = TypeMatchers.isType("contextlib.suppress");
94+
95+
private static final TypeMatcher AIRFLOW_TYPE_MATCHER = TypeMatchers.any(
96+
TypeMatchers.isObjectInstanceOf("airflow.models.baseoperator.BaseOperator"),
97+
TypeMatchers.isObjectInstanceOf("airflow.models.dag.DAG"),
98+
TypeMatchers.isObjectInstanceOf("airflow.models.taskmixin.DependencyMixin")
99+
);
100+
101+
private static final TypeMatcher DAG_CLASS_TYPE_MATCHER = TypeMatchers.isType("airflow.models.dag.DAG");
102+
103+
private static final TypeMatcher DAG_DECORATOR_TYPE_MATCHER = TypeMatchers.isType("airflow.decorators.dag");
104+
94105
@Override
95106
public void initialize(Context context) {
96107
context.registerSyntaxNodeConsumer(Kind.STRING_LITERAL, this::checkStringLiteral);
@@ -118,56 +129,55 @@ private static void checkNode(SubscriptionContext ctx) {
118129
if (parent == null || !parent.is(Kind.EXPRESSION_STMT)) {
119130
return;
120131
}
121-
if (isWithinIgnoredContext(tree)) {
132+
if (isWithinIgnoredContext(tree, ctx)) {
122133
return;
123134
}
124135
// Safe cast because the rule only subscribes to expressions
125-
if (isAnAirflowException((Expression) tree)) {
136+
if (isAnAirflowException((Expression) tree, ctx)) {
126137
return;
127138
}
128139
ctx.addIssue(tree, MESSAGE);
129140
}
130141

131-
private static boolean isAnAirflowException(Expression expression) {
132-
if (isWithinAirflowContext(expression)) {
142+
private static boolean isAnAirflowException(Expression expression, SubscriptionContext ctx) {
143+
if (isWithinAirflowContext(expression, ctx)) {
133144
StatementList statementList = (StatementList) TreeUtils.firstAncestorOfKind(expression, Kind.STATEMENT_LIST);
134145
return Optional.ofNullable(statementList).map(StatementList::statements).map(statements -> statements.get(statements.size() - 1))
135146
.filter(lastStatement -> lastStatement.equals(TreeUtils.firstAncestorOfKind(expression, Kind.EXPRESSION_STMT))).isPresent();
136147
}
137148
return false;
138149
}
139150

140-
private static boolean isWithinIgnoredContext(Tree tree) {
151+
private static boolean isWithinIgnoredContext(Tree tree, SubscriptionContext ctx) {
141152
Tree withParent = TreeUtils.firstAncestorOfKind(tree, Kind.WITH_STMT);
142153
if (withParent != null) {
143154
WithStatement withStatement = (WithStatement) withParent;
144155
return withStatement.withItems().stream()
145156
.map(WithItem::test)
146157
.filter(item -> item.is(Kind.CALL_EXPR))
147-
.map(item -> ((CallExpression) item).calleeSymbol())
148-
.filter(Objects::nonNull)
149-
.anyMatch(s -> ignoredContexts.contains(s.fullyQualifiedName()));
158+
.map(item -> ((CallExpression) item).callee())
159+
.anyMatch(callee -> CONTEXTLIB_SUPPRESS_TYPE_MATCHER.isTrueFor(callee, ctx));
150160
}
151161
return false;
152162
}
153163

154-
private static boolean isWithinAirflowContext(Tree tree) {
164+
private static boolean isWithinAirflowContext(Tree tree, SubscriptionContext ctx) {
155165
Tree withParent = TreeUtils.firstAncestorOfKind(tree, Kind.WITH_STMT);
156166
while (withParent != null) {
157167
WithStatement withStatement = (WithStatement) withParent;
158168
if (withStatement.withItems().stream()
159169
.map(WithItem::test)
160170
.filter(item -> item.is(Kind.CALL_EXPR))
161-
.map(item -> ((CallExpression) item).calleeSymbol())
162-
.filter(Objects::nonNull)
163-
.anyMatch(s -> "airflow.DAG".equals(s.fullyQualifiedName()))) {
171+
.map(item -> ((CallExpression) item).callee())
172+
.anyMatch(callee -> DAG_CLASS_TYPE_MATCHER.isTrueFor(callee, ctx))) {
164173
return true;
165174
}
166175
withParent = TreeUtils.firstAncestorOfKind(withParent, Kind.WITH_STMT);
167176
}
168177
FunctionDef funcParent = (FunctionDef) TreeUtils.firstAncestorOfKind(tree, Kind.FUNCDEF);
169-
return funcParent != null && funcParent.decorators().stream().map(deco -> TreeUtils.getSymbolFromTree(deco.expression())).filter(Optional::isPresent)
170-
.anyMatch(symbol -> "airflow.decorators.dag".equals(symbol.get().fullyQualifiedName()));
178+
return funcParent != null && funcParent.decorators().stream()
179+
.map(Decorator::expression)
180+
.anyMatch(expr -> DAG_DECORATOR_TYPE_MATCHER.isTrueFor(expr, ctx));
171181
}
172182

173183
private static boolean isBooleanExpressionWithCalls(Tree tree) {
@@ -192,25 +202,25 @@ private void checkStringLiteral(SubscriptionContext ctx) {
192202

193203
private static void checkName(SubscriptionContext ctx) {
194204
Name name = (Name) ctx.syntaxNode();
195-
Symbol symbol = name.symbol();
196-
if (symbol != null && symbol.is(Symbol.Kind.CLASS)) {
197-
ClassSymbol classSymbol = (ClassSymbol) symbol;
198-
// Creating an exception without raising it is covered by S3984
199-
if (classSymbol.canBeOrExtend("BaseException")) {
200-
return;
201-
}
205+
// Creating an exception without raising it is covered by S3984
206+
if (EXCEPTION_CLASS_TYPE_MATCHER.isTrueFor(name, ctx)) {
207+
return;
208+
}
209+
// Avoid raising on useless statements made to suppress issues due to "unused" import
210+
SymbolV2 symbolV2 = name.symbolV2();
211+
if (symbolV2 != null && symbolV2.usages().stream().anyMatch(u -> u.kind() == UsageV2.Kind.IMPORT) && symbolV2.usages().size() == 2) {
212+
return;
202213
}
203-
if (symbol != null && symbol.usages().stream().anyMatch(u -> u.kind().equals(Usage.Kind.IMPORT)) && symbol.usages().size() == 2) {
204-
// Avoid raising on useless statements made to suppress issues due to "unused" import
214+
if (AIRFLOW_TYPE_MATCHER.isTrueFor(name, ctx)) {
205215
return;
206216
}
207217
checkNode(ctx);
208218
}
209219

210220
private static void checkQualifiedExpression(SubscriptionContext ctx) {
211221
QualifiedExpression qualifiedExpression = (QualifiedExpression) ctx.syntaxNode();
212-
Symbol symbol = qualifiedExpression.symbol();
213-
if (symbol != null && symbol.is(Symbol.Kind.FUNCTION) && ((FunctionSymbol) symbol).decorators().stream().noneMatch(d -> d.matches("property"))) {
222+
// Only raise on functions; properties are already resolved to their return type by the type inference engine
223+
if (qualifiedExpression.typeV2() instanceof FunctionType) {
214224
checkNode(ctx);
215225
}
216226
}

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ class MyClass:
66

77
def method(self): ...
88

9+
def __eq__(self, other): ...
10+
911
@classmethod
1012
def class_method(cls): ...
1113

@@ -149,7 +151,7 @@ def airflow_ignore_context_false_negative():
149151
ping = HttpOperator(endpoint="http://example.com/update/")
150152
download = HttpOperator(endpoint="http://example.com/download/")
151153
upload = HttpOperator(endpoint="http://example.com/upload/")
152-
ping # Noncompliant
154+
ping
153155
download >> upload
154156

155157
from airflow.decorators import dag
@@ -165,7 +167,7 @@ def airflow_decorator_multiple_statements():
165167
download = HttpOperator(endpoint="http://example.com/download/")
166168
upload = HttpOperator(endpoint="http://example.com/upload/")
167169
download >> upload
168-
ping # Noncompliant
170+
ping
169171
ping >> upload
170172

171173
def airflow_nested_with():
@@ -174,7 +176,7 @@ def airflow_nested_with():
174176
ping = HttpOperator(endpoint="http://example.com/update/")
175177
download = HttpOperator(endpoint="http://example.com/download/")
176178
upload = HttpOperator(endpoint="http://example.com/upload/")
177-
ping # Noncompliant
179+
ping
178180
download >> upload
179181

180182
@dag
@@ -189,3 +191,19 @@ def airflow_ignore_context_not_operator():
189191
some_other_var = True
190192
some_var # Noncompliant
191193
some_other_var
194+
195+
from airflow.providers.amazon.aws.operators.ecs import EcsRunTaskOperator
196+
from airflow.models.baseoperator import BaseOperator as AirflowBaseOperator
197+
198+
_module_dag = DAG(dag_id="example_dag", schedule_interval=None)
199+
_ecs_task = EcsRunTaskOperator(task_id="generate_yaml", dag=_module_dag)
200+
_ecs_task
201+
202+
_http_task = HttpOperator(task_id="http", endpoint="/", dag=_module_dag)
203+
_http_task
204+
205+
class _CustomOperator(AirflowBaseOperator):
206+
def execute(self, context): pass
207+
208+
_custom_task = _CustomOperator(task_id="custom", dag=_module_dag)
209+
_custom_task

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
airflow.decorators�
3+
dagairflow.decorators.dag"K
4+
CallableType[builtins.function]&
5+
builtins.function"builtins.function*
6+
args
7+
Any*
8+
kwargs
9+
Any*s
10+
__path__airflow.decorators.__path__J
11+
builtins.list[builtins.str]
12+
builtins.str" builtins.str"builtins.list*�
13+
__annotations__"airflow.decorators.__annotations__W
14+
builtins.dict[builtins.str,Any]
15+
builtins.str" builtins.str
16+
Any"builtins.dict

python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/airflow.models.baseoperator.protobuf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
airflow.models.baseoperator�
3+
BaseOperator(airflow.models.baseoperator.BaseOperator"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
4+
__init__1airflow.models.baseoperator.BaseOperator.__init__"
5+
None*^
6+
selfT
7+
(airflow.models.baseoperator.BaseOperator"(airflow.models.baseoperator.BaseOperator*
8+
args
9+
Any*
10+
kwargs
11+
Any*�
12+
__annotations__+airflow.models.baseoperator.__annotations__W
13+
builtins.dict[builtins.str,Any]
14+
builtins.str" builtins.str
15+
Any"builtins.dict

python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/airflow.models.dag.protobuf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
airflow.models.dag�
3+
DAGairflow.models.dag.DAG"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
4+
__init__airflow.models.dag.DAG.__init__"
5+
None*:
6+
self0
7+
airflow.models.dag.DAG"airflow.models.dag.DAG*
8+
args
9+
Any*
10+
kwargs
11+
Any*�
12+
__annotations__"airflow.models.dag.__annotations__W
13+
builtins.dict[builtins.str,Any]
14+
builtins.str" builtins.str
15+
Any"builtins.dict

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
2+
airflow.models�
3+
BaseOperator(airflow.models.baseoperator.BaseOperator"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
4+
__init__1airflow.models.baseoperator.BaseOperator.__init__"
5+
None*^
6+
selfT
7+
(airflow.models.baseoperator.BaseOperator"(airflow.models.baseoperator.BaseOperator*
8+
args
9+
Any*
10+
kwargs
11+
Any�
12+
DAGairflow.models.dag.DAG"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
13+
__init__airflow.models.dag.DAG.__init__"
14+
None*:
15+
self0
16+
airflow.models.dag.DAG"airflow.models.dag.DAG*
17+
args
18+
Any*
19+
kwargs
20+
Any�
21+
DependencyMixin(airflow.models.taskmixin.DependencyMixin"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
22+
__init__1airflow.models.taskmixin.DependencyMixin.__init__"
23+
None*^
24+
selfT
25+
(airflow.models.taskmixin.DependencyMixin"(airflow.models.taskmixin.DependencyMixin*
26+
args
27+
Any*
28+
kwargs
29+
Any*o
30+
__path__airflow.models.__path__J
31+
builtins.list[builtins.str]
32+
builtins.str" builtins.str"builtins.list*�
33+
__annotations__airflow.models.__annotations__W
34+
builtins.dict[builtins.str,Any]
35+
builtins.str" builtins.str
36+
Any"builtins.dict

python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/airflow.models.taskmixin.protobuf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
airflow.models.taskmixin�
3+
DependencyMixin(airflow.models.taskmixin.DependencyMixin"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
4+
__init__1airflow.models.taskmixin.DependencyMixin.__init__"
5+
None*^
6+
selfT
7+
(airflow.models.taskmixin.DependencyMixin"(airflow.models.taskmixin.DependencyMixin*
8+
args
9+
Any*
10+
kwargs
11+
Any*�
12+
__annotations__(airflow.models.taskmixin.__annotations__W
13+
builtins.dict[builtins.str,Any]
14+
builtins.str" builtins.str
15+
Any"builtins.dict

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
2+
airflow�
3+
DAGairflow.models.dag.DAG"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
4+
__init__airflow.models.dag.DAG.__init__"
5+
None*:
6+
self0
7+
airflow.models.dag.DAG"airflow.models.dag.DAG*
8+
args
9+
Any*
10+
kwargs
11+
Any�
12+
BaseOperator(airflow.models.baseoperator.BaseOperator"*SonarPythonAnalyzerFakeStub.CustomStubBase*�
13+
__init__1airflow.models.baseoperator.BaseOperator.__init__"
14+
None*^
15+
selfT
16+
(airflow.models.baseoperator.BaseOperator"(airflow.models.baseoperator.BaseOperator*
17+
args
18+
Any*
19+
kwargs
20+
Any*h
21+
__path__airflow.__path__J
22+
builtins.list[builtins.str]
23+
builtins.str" builtins.str"builtins.list*�
24+
__annotations__airflow.__annotations__W
25+
builtins.dict[builtins.str,Any]
26+
builtins.str" builtins.str
27+
Any"builtins.dict

python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/airflow.providers.amazon.aws.operators.ecs.protobuf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
2+
*airflow.providers.amazon.aws.operators.ecs�
3+
EcsRunTaskOperator=airflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator"(airflow.models.baseoperator.BaseOperator*�
4+
__init__Fairflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator.__init__"
5+
None*�
6+
self~
7+
=airflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator"=airflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator*
8+
args
9+
Any*
10+
kwargs
11+
Any*�
12+
__annotations__:airflow.providers.amazon.aws.operators.ecs.__annotations__W
13+
builtins.dict[builtins.str,Any]
14+
builtins.str" builtins.str
15+
Any"builtins.dict

python-frontend/src/main/resources/org/sonar/python/types/custom_protobuf/airflow.providers.amazon.aws.operators.protobuf

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
2+
&airflow.providers.amazon.aws.operators�
3+
EcsRunTaskOperator=airflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator"(airflow.models.baseoperator.BaseOperator*�
4+
__init__Fairflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator.__init__"
5+
None*�
6+
self~
7+
=airflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator"=airflow.providers.amazon.aws.operators.ecs.EcsRunTaskOperator*
8+
args
9+
Any*
10+
kwargs
11+
Any*�
12+
__path__/airflow.providers.amazon.aws.operators.__path__J
13+
builtins.list[builtins.str]
14+
builtins.str" builtins.str"builtins.list*�
15+
__annotations__6airflow.providers.amazon.aws.operators.__annotations__W
16+
builtins.dict[builtins.str,Any]
17+
builtins.str" builtins.str
18+
Any"builtins.dict

0 commit comments

Comments
 (0)