Skip to content

Commit 816253b

Browse files
Seppli11sonartech
authored andcommitted
SONARPY-3218 Implement an utility to resolve the type of a globally defined variable (#417)
GitOrigin-RevId: 70903a67ad60fa3b9a4e98b0819a7dec42ecfd50
1 parent 2b84712 commit 816253b

File tree

4 files changed

+289
-6
lines changed

4 files changed

+289
-6
lines changed

python-frontend/src/main/java/org/sonar/python/semantic/v2/SymbolV2.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.util.ArrayList;
2020
import java.util.List;
21+
import java.util.Optional;
2122
import org.sonar.api.Beta;
2223
import org.sonar.plugins.python.api.tree.Name;
2324
import org.sonar.python.tree.NameImpl;
@@ -47,7 +48,20 @@ void addUsage(Name name, UsageV2.Kind kind) {
4748

4849
@Beta
4950
public boolean hasSingleBindingUsage() {
50-
return usages.stream().filter(UsageV2::isBindingUsage).toList().size() == 1;
51+
return getSingleBindingUsage().isPresent();
52+
}
53+
54+
@Beta
55+
public Optional<UsageV2> getSingleBindingUsage() {
56+
List<UsageV2> bindingUsages = usages().stream()
57+
.filter(UsageV2::isBindingUsage)
58+
.toList();
59+
60+
if(bindingUsages.size() == 1) {
61+
return Optional.of(bindingUsages.get(0));
62+
}
63+
64+
return Optional.empty();
5165
}
5266

5367
public String name() {

python-frontend/src/main/java/org/sonar/python/tree/TreeUtils.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,10 @@
5858
import org.sonar.plugins.python.api.tree.Tree;
5959
import org.sonar.plugins.python.api.tree.Tree.Kind;
6060
import org.sonar.plugins.python.api.tree.Tuple;
61+
import org.sonar.plugins.python.api.types.v2.PythonType;
6162
import org.sonar.python.api.PythonTokenType;
63+
import org.sonar.python.semantic.v2.SymbolV2;
64+
import org.sonar.python.semantic.v2.UsageV2;
6265

6366
public class TreeUtils {
6467
private TreeUtils() {
@@ -553,4 +556,54 @@ private static Optional<String> extractStringValueFromQualifiedExpression(Qualif
553556
return Optional.empty();
554557
}
555558

559+
/**
560+
* <p>
561+
* This method returns the type of an expression.
562+
* </p>
563+
*
564+
* <p>
565+
* If the expression is a name, the function will find where the name was assigned and return the type of the assigned expression.
566+
* If the expression is a qualified expression, it will resolve the type of the qualifier and then resolve the member type on that type.
567+
* The function will do that recursively until it finds a name or resolves something to an {@link PythonType#UNKNOWN} type.
568+
* If the expression is neither a name nor a qualified expression, it will return {@link Expression#typeV2()}.
569+
* </p>
570+
*
571+
* <p>
572+
* This function is intended to used with variables that are defined in the global scope, and then used in a function. Since
573+
* type inference does not propagate types from global scope to function scope, this would leave the name of the variable in the function
574+
* without a type.
575+
* </p>
576+
*
577+
* <p>
578+
* See SONARPY-3218 and SONARPY-3219 for more details.
579+
* </p>
580+
*
581+
* @param name the name of a variable that is assigned in the global scope
582+
* @return the type of the name, or `UNKNOWN` if it cannot be determined
583+
*/
584+
public static PythonType inferSingleAssignedExpressionType(Expression expr) {
585+
if(expr.typeV2() != PythonType.UNKNOWN) {
586+
return expr.typeV2();
587+
}
588+
589+
if(expr instanceof Name name) {
590+
return inferSingleAssignedNameType(name);
591+
} else if (expr instanceof QualifiedExpression qualifiedExpr) {
592+
PythonType qualifierType = inferSingleAssignedExpressionType(qualifiedExpr.qualifier());
593+
return qualifierType.resolveMember(qualifiedExpr.name().name()).orElse(PythonType.UNKNOWN);
594+
} else {
595+
return PythonType.UNKNOWN;
596+
}
597+
}
598+
599+
private static PythonType inferSingleAssignedNameType(Name name) {
600+
Optional<Name> bindingName = Optional.ofNullable(name.symbolV2())
601+
.flatMap(SymbolV2::getSingleBindingUsage)
602+
.map(UsageV2::tree)
603+
.flatMap(TreeUtils.toOptionalInstanceOfMapper(Name.class));
604+
605+
return bindingName
606+
.map(Name::typeV2)
607+
.orElse(PythonType.UNKNOWN);
608+
}
556609
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2025 SonarSource SA
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.semantic.v2;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.mockito.Mockito.mock;
21+
22+
import java.util.Optional;
23+
import org.junit.jupiter.api.Test;
24+
import org.sonar.plugins.python.api.tree.Name;
25+
26+
class SymbolV2Test {
27+
@Test
28+
void getSingleBindingUsage_should_return_empty_for_no_usages() {
29+
SymbolV2 symbol = new SymbolV2("testSymbol");
30+
assertThat(symbol.getSingleBindingUsage()).isEmpty();
31+
}
32+
33+
@Test
34+
void getSingleBindingUsage_should_return_empty_for_multiple_binding_usages() {
35+
SymbolV2 symbol = new SymbolV2("testSymbol");
36+
Name name1 = mock(Name.class);
37+
Name name2 = mock(Name.class);
38+
39+
symbol.addUsage(name1, UsageV2.Kind.IMPORT);
40+
symbol.addUsage(name2, UsageV2.Kind.ASSIGNMENT_LHS);
41+
42+
assertThat(symbol.getSingleBindingUsage()).isEmpty();
43+
}
44+
45+
@Test
46+
void getSingleBindingUsage_should_return_binding_usage_when_exactly_one_exists() {
47+
SymbolV2 symbol = new SymbolV2("testSymbol");
48+
Name name1 = mock(Name.class);
49+
Name name2 = mock(Name.class);
50+
51+
symbol.addUsage(name1, UsageV2.Kind.IMPORT);
52+
symbol.addUsage(name2, UsageV2.Kind.OTHER);
53+
54+
Optional<UsageV2> singleBindingUsage = symbol.getSingleBindingUsage();
55+
assertThat(singleBindingUsage).isPresent();
56+
assertThat(singleBindingUsage.get().kind()).isEqualTo(UsageV2.Kind.IMPORT);
57+
assertThat(singleBindingUsage.get().tree()).isEqualTo(name1);
58+
}
59+
60+
@Test
61+
void hasSingleBindingUsage_should_return_false_for_no_usages() {
62+
SymbolV2 symbol = new SymbolV2("testSymbol");
63+
assertThat(symbol.hasSingleBindingUsage()).isFalse();
64+
}
65+
66+
@Test
67+
void hasSingleBindingUsage_should_return_false_for_multiple_binding_usages() {
68+
SymbolV2 symbol = new SymbolV2("testSymbol");
69+
Name name1 = mock(Name.class);
70+
Name name2 = mock(Name.class);
71+
72+
symbol.addUsage(name1, UsageV2.Kind.IMPORT);
73+
symbol.addUsage(name2, UsageV2.Kind.ASSIGNMENT_LHS);
74+
75+
assertThat(symbol.hasSingleBindingUsage()).isFalse();
76+
}
77+
78+
@Test
79+
void hasSingleBindingUsage_should_return_true_for_single_binding_usage() {
80+
SymbolV2 symbol = new SymbolV2("testSymbol");
81+
Name name1 = mock(Name.class);
82+
Name name2 = mock(Name.class);
83+
84+
symbol.addUsage(name1, UsageV2.Kind.IMPORT);
85+
symbol.addUsage(name2, UsageV2.Kind.OTHER);
86+
87+
assertThat(symbol.hasSingleBindingUsage()).isTrue();
88+
}
89+
90+
@Test
91+
void hasSingleBindingUsage_should_return_false_for_only_non_binding_usages() {
92+
SymbolV2 symbol = new SymbolV2("testSymbol");
93+
Name name = mock(Name.class);
94+
95+
symbol.addUsage(name, UsageV2.Kind.OTHER);
96+
97+
assertThat(symbol.hasSingleBindingUsage()).isFalse();
98+
}
99+
}

python-frontend/src/test/java/org/sonar/python/tree/TreeUtilsTest.java

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616
*/
1717
package org.sonar.python.tree;
1818

19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
21+
import static org.mockito.Mockito.mock;
22+
import static org.mockito.Mockito.when;
23+
import static org.sonar.python.PythonTestUtils.lastExpression;
24+
import static org.sonar.python.PythonTestUtils.pythonFile;
25+
1926
import com.sonar.sslr.api.AstNode;
2027
import java.util.ArrayList;
2128
import java.util.Arrays;
@@ -30,6 +37,7 @@
3037
import org.sonar.plugins.python.api.tree.ClassDef;
3138
import org.sonar.plugins.python.api.tree.DottedName;
3239
import org.sonar.plugins.python.api.tree.Expression;
40+
import org.sonar.plugins.python.api.tree.ExpressionStatement;
3341
import org.sonar.plugins.python.api.tree.FileInput;
3442
import org.sonar.plugins.python.api.tree.FunctionDef;
3543
import org.sonar.plugins.python.api.tree.HasSymbol;
@@ -44,15 +52,15 @@
4452
import org.sonar.plugins.python.api.tree.Tree;
4553
import org.sonar.plugins.python.api.tree.Tree.Kind;
4654
import org.sonar.plugins.python.api.tree.WhileStatement;
55+
import org.sonar.plugins.python.api.types.v2.ClassType;
56+
import org.sonar.plugins.python.api.types.v2.FunctionType;
57+
import org.sonar.plugins.python.api.types.v2.ObjectType;
58+
import org.sonar.plugins.python.api.types.v2.PythonType;
4759
import org.sonar.python.PythonTestUtils;
4860
import org.sonar.python.api.PythonTokenType;
4961
import org.sonar.python.parser.PythonParser;
5062
import org.sonar.python.semantic.SymbolTableBuilder;
51-
52-
import static org.assertj.core.api.Assertions.assertThat;
53-
import static org.assertj.core.api.Assertions.assertThatThrownBy;
54-
import static org.sonar.python.PythonTestUtils.lastExpression;
55-
import static org.sonar.python.PythonTestUtils.pythonFile;
63+
import org.sonar.python.types.v2.TypesTestUtils;
5664

5765
class TreeUtilsTest {
5866

@@ -726,6 +734,115 @@ void test_stringValueFromNameOrQualifiedExpression() {
726734
assertThat(TreeUtils.stringValueFromNameOrQualifiedExpression(expression)).isEmpty();
727735
}
728736

737+
738+
@Test
739+
void testGetTypeOfSingleAssignedName() {
740+
var tree = TypesTestUtils.parseAndInferTypes("""
741+
a = 1
742+
def foo():
743+
a
744+
""");
745+
746+
FunctionDef functionDef = PythonTestUtils.getFirstChild(tree, t -> t.is(Tree.Kind.FUNCDEF));
747+
Name name = PythonTestUtils.getFirstChild(functionDef.body(), t -> t.is(Tree.Kind.NAME));
748+
749+
assertThat(name.name()).isEqualTo("a");
750+
751+
assertThat(TreeUtils.inferSingleAssignedExpressionType(name))
752+
.isInstanceOfSatisfying(ObjectType.class, nameType ->
753+
assertThat(nameType.unwrappedType()).isSameAs(TypesTestUtils.INT_TYPE));
754+
}
755+
756+
@Test
757+
void testGetTypeOfSingleAssignedName_qualifiedExpr() {
758+
var tree = TypesTestUtils.parseAndInferTypes("""
759+
import requests
760+
session = requests.Session()
761+
def foo():
762+
session.get
763+
session.cookies.copy
764+
Session().get
765+
requests.get
766+
requests.get()
767+
""");
768+
769+
FunctionDef functionDef = PythonTestUtils.getFirstChild(tree, t -> t.is(Tree.Kind.FUNCDEF));
770+
List<Expression> expressions = functionDef.body().statements().stream()
771+
.map(statement -> ((ExpressionStatement) statement).expressions().get(0))
772+
.toList();
773+
774+
775+
QualifiedExpression sessionGetExpr = (QualifiedExpression) expressions.get(0);
776+
assertThat(TreeUtils.inferSingleAssignedExpressionType(sessionGetExpr))
777+
.isInstanceOfSatisfying(FunctionType.class, functionType ->
778+
assertThat(functionType.name()).isEqualTo("get"));
779+
780+
QualifiedExpression cookiesCopyExpr = (QualifiedExpression) expressions.get(1);
781+
assertThat(TreeUtils.inferSingleAssignedExpressionType(cookiesCopyExpr))
782+
.isInstanceOfSatisfying(FunctionType.class, functionType ->
783+
assertThat(functionType.name()).isEqualTo("copy"));
784+
785+
QualifiedExpression sessionInstanceGet = (QualifiedExpression) expressions.get(2);
786+
assertThat(TreeUtils.inferSingleAssignedExpressionType(sessionInstanceGet))
787+
.isSameAs(PythonType.UNKNOWN);
788+
789+
QualifiedExpression requestGetExpr = (QualifiedExpression) expressions.get(3);
790+
assertThat(TreeUtils.inferSingleAssignedExpressionType(requestGetExpr))
791+
.isInstanceOfSatisfying(FunctionType.class, functionType ->
792+
assertThat(functionType.name()).isEqualTo("get"));
793+
794+
CallExpression requestsGetCallExpr = (CallExpression) expressions.get(4);
795+
assertThat(TreeUtils.inferSingleAssignedExpressionType(requestsGetCallExpr))
796+
.isInstanceOf(ObjectType.class)
797+
.extracting(PythonType::unwrappedType)
798+
.isInstanceOfSatisfying(ClassType.class, classType ->
799+
assertThat(classType.name()).isEqualTo("Response"));
800+
}
801+
802+
803+
@Test
804+
void testGetTypeOfSingleAssignedName_multipleAssignment() {
805+
var tree = TypesTestUtils.parseAndInferTypes("""
806+
a = 1
807+
def foo():
808+
a
809+
a = 12
810+
""");
811+
812+
FunctionDef functionDef = PythonTestUtils.getFirstChild(tree, t -> t.is(Tree.Kind.FUNCDEF));
813+
Name name = PythonTestUtils.getFirstChild(functionDef.body(), t -> t.is(Tree.Kind.NAME));
814+
815+
assertThat(name.name()).isEqualTo("a");
816+
817+
assertThat(TreeUtils.inferSingleAssignedExpressionType(name))
818+
.isSameAs(PythonType.UNKNOWN);
819+
}
820+
821+
@Test
822+
void testGetTypeOfSingleAssignedName_sameScope() {
823+
var tree = TypesTestUtils.parseAndInferTypes("""
824+
a = 1
825+
a
826+
""");
827+
828+
Name name = PythonTestUtils.getLastDescendant(tree, t -> t.is(Tree.Kind.NAME));
829+
assertThat(name.name()).isEqualTo("a");
830+
831+
assertThat(TreeUtils.inferSingleAssignedExpressionType(name))
832+
.isInstanceOfSatisfying(ObjectType.class, nameType ->
833+
assertThat(nameType.unwrappedType()).isSameAs(TypesTestUtils.INT_TYPE));
834+
}
835+
836+
@Test
837+
void testGetTypeOfSingleAssignedName_noSymbol() {
838+
var name = mock(Name.class);
839+
when(name.symbolV2()).thenReturn(null);
840+
when(name.typeV2()).thenReturn(PythonType.UNKNOWN);
841+
842+
assertThat(TreeUtils.inferSingleAssignedExpressionType(name))
843+
.isSameAs(PythonType.UNKNOWN);
844+
}
845+
729846
private static boolean isOuterFunction(Tree tree) {
730847
return tree.is(Kind.FUNCDEF) && ((FunctionDef) tree).name().name().equals("outer");
731848
}

0 commit comments

Comments
 (0)