Skip to content

Commit 9c2ddd6

Browse files
Seppli11sonartech
authored andcommitted
SONARPY-3558 Add telemetry about miss-classified test/main code (#698)
GitOrigin-RevId: bd10c810f495da6214dba9cf8281d36cf7262583
1 parent 2ce0852 commit 9c2ddd6

21 files changed

+545
-13
lines changed

python-commons/src/main/java/org/sonar/plugins/python/DependencyTelemetrySensor.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.sonar.api.batch.sensor.SensorContext;
2121
import org.sonar.api.batch.sensor.SensorDescriptor;
2222
import org.sonar.plugins.python.dependency.DependencyTelemetry;
23+
import org.sonar.plugins.python.telemetry.SensorTelemetryStorage;
2324

2425
public class DependencyTelemetrySensor implements Sensor {
2526
private final SensorTelemetryStorage sensorTelemetryStorage = new SensorTelemetryStorage();

python-commons/src/main/java/org/sonar/plugins/python/IPynbSensor.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@
4141
import org.sonar.plugins.python.indexer.PythonIndexer;
4242
import org.sonar.plugins.python.indexer.SonarQubePythonIndexer;
4343
import org.sonar.plugins.python.nosonar.NoSonarLineInfoCollector;
44-
import org.sonar.python.project.config.ProjectConfigurationBuilder;
44+
import org.sonar.plugins.python.telemetry.SensorTelemetryStorage;
45+
import org.sonar.plugins.python.telemetry.TelemetryMetricKey;
4546
import org.sonar.python.caching.CacheContextImpl;
4647
import org.sonar.python.parser.PythonParser;
48+
import org.sonar.python.project.config.ProjectConfigurationBuilder;
4749

4850
import static org.sonar.plugins.python.api.PythonVersionUtils.PYTHON_VERSION_KEY;
4951

python-commons/src/main/java/org/sonar/plugins/python/PythonScanner.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@
5252
import org.sonar.plugins.python.cpd.PythonCpdAnalyzer;
5353
import org.sonar.plugins.python.indexer.PythonIndexer;
5454
import org.sonar.plugins.python.nosonar.NoSonarLineInfoCollector;
55+
import org.sonar.plugins.python.telemetry.collectors.TestFileTelemetry;
56+
import org.sonar.plugins.python.telemetry.collectors.TestFileTelemetryCollector;
57+
import org.sonar.plugins.python.telemetry.collectors.TypeInferenceTelemetry;
58+
import org.sonar.plugins.python.telemetry.collectors.TypeInferenceTelemetryCollector;
5559
import org.sonar.python.IPythonLocation;
5660
import org.sonar.python.SubscriptionVisitor;
5761
import org.sonar.python.parser.PythonParser;
@@ -80,6 +84,7 @@ public class PythonScanner extends Scanner {
8084
private final NoSonarLineInfoCollector noSonarLineInfoCollector;
8185
private final Lock lock;
8286
private final TypeInferenceTelemetryCollector typeInferenceTelemetryCollector;
87+
private final TestFileTelemetryCollector testFileTelemetryCollector;
8388

8489
public PythonScanner(
8590
SensorContext context, PythonChecks checks, FileLinesContextFactory fileLinesContextFactory, NoSonarFilter noSonarFilter,
@@ -102,6 +107,7 @@ public PythonScanner(
102107
this.issuesRepository = new IssuesRepository(context, checks, indexer, isInSonarLint(context), lock);
103108
this.measuresRepository = new MeasuresRepository(context, noSonarFilter, fileLinesContextFactory, isInSonarLint(context), noSonarLineInfoCollector, lock);
104109
this.typeInferenceTelemetryCollector = new TypeInferenceTelemetryCollector();
110+
this.testFileTelemetryCollector = new TestFileTelemetryCollector();
105111
}
106112

107113
@Override
@@ -141,6 +147,7 @@ protected void scanFile(PythonInputFile inputFile) throws IOException {
141147
newSymbolsCollector.collect(context.newSymbolTable().onFile(inputFile.wrappedFile()), visitorContext.rootTree());
142148
pythonHighlighter.highlight(context, visitorContext, inputFile);
143149
typeInferenceTelemetryCollector.collect(visitorContext.rootTree());
150+
testFileTelemetryCollector.collect(visitorContext.rootTree(), fileType);
144151
}
145152

146153
searchForDataBricks(visitorContext);
@@ -362,6 +369,10 @@ public TypeInferenceTelemetry getTypeInferenceTelemetry() {
362369
return typeInferenceTelemetryCollector.getTelemetry();
363370
}
364371

372+
public TestFileTelemetry getTestFileTelemetry() {
373+
return testFileTelemetryCollector.getTelemetry();
374+
}
375+
365376
private void runLockedByRepository(String repositoryKey, Runnable runnable) {
366377
var repositoryLock = repositoryLocks.computeIfAbsent(repositoryKey, k -> new ReentrantLock());
367378
try {

python-commons/src/main/java/org/sonar/plugins/python/PythonSensor.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@
5151
import org.sonar.plugins.python.indexer.PythonIndexerWrapper;
5252
import org.sonar.plugins.python.indexer.SonarQubePythonIndexer;
5353
import org.sonar.plugins.python.nosonar.NoSonarLineInfoCollector;
54+
import org.sonar.plugins.python.telemetry.SensorTelemetryStorage;
55+
import org.sonar.plugins.python.telemetry.TelemetryMetricKey;
56+
import org.sonar.plugins.python.telemetry.collectors.TestFileTelemetry;
57+
import org.sonar.plugins.python.telemetry.collectors.TypeInferenceTelemetry;
5458
import org.sonar.plugins.python.warnings.AnalysisWarningsWrapper;
5559
import org.sonar.python.caching.CacheContextImpl;
5660
import org.sonar.python.parser.PythonParser;
@@ -154,6 +158,7 @@ public void execute(SensorContext context) {
154158

155159
updateDatabricksTelemetry(scanner);
156160
updateTypeInferenceTelemetry(scanner);
161+
updateTestFileTelemetry(scanner);
157162
sensorTelemetryStorage.updateMetric(TelemetryMetricKey.NOSONAR_RULE_ID_KEY, noSonarLineInfoCollector.getSuppressedRuleIds());
158163
sensorTelemetryStorage.updateMetric(TelemetryMetricKey.NOSONAR_COMMENTS_KEY, noSonarLineInfoCollector.getCommentWithExactlyOneRuleSuppressed());
159164
updateNamespacePackageTelemetry(pythonIndexer);
@@ -193,6 +198,12 @@ private void updateTypeInferenceTelemetry(PythonScanner scanner) {
193198
sensorTelemetryStorage.updateMetric(TelemetryMetricKey.PYTHON_TYPES_SYMBOLS_UNKNOWN, telemetry.unknownSymbols());
194199
}
195200

201+
private void updateTestFileTelemetry(PythonScanner scanner) {
202+
TestFileTelemetry telemetry = scanner.getTestFileTelemetry();
203+
sensorTelemetryStorage.updateMetric(TelemetryMetricKey.PYTHON_MAIN_FILES_TOTAL, telemetry.totalMainFiles());
204+
sensorTelemetryStorage.updateMetric(TelemetryMetricKey.PYTHON_MAIN_FILES_MISCLASSIFIED_TEST, telemetry.misclassifiedTestFiles());
205+
}
206+
196207
private void updateNamespacePackageTelemetry(PythonIndexer pythonIndexer) {
197208
NamespacePackageTelemetry telemetry = pythonIndexer.namespacePackageTelemetry();
198209
if (telemetry != null) {

python-commons/src/main/java/org/sonar/plugins/python/dependency/DependencyTelemetry.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
import java.util.stream.Stream;
2323
import org.sonar.api.batch.fs.FileSystem;
2424
import org.sonar.api.batch.fs.InputFile;
25-
import org.sonar.plugins.python.SensorTelemetryStorage;
26-
import org.sonar.plugins.python.TelemetryMetricKey;
2725
import org.sonar.plugins.python.dependency.model.Dependencies;
2826
import org.sonar.plugins.python.dependency.model.Dependency;
27+
import org.sonar.plugins.python.telemetry.SensorTelemetryStorage;
28+
import org.sonar.plugins.python.telemetry.TelemetryMetricKey;
2929

3030
public class DependencyTelemetry {
3131
/**

python-commons/src/main/java/org/sonar/plugins/python/SensorTelemetryStorage.java renamed to python-commons/src/main/java/org/sonar/plugins/python/telemetry/SensorTelemetryStorage.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* You should have received a copy of the Sonar Source-Available License
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
17-
package org.sonar.plugins.python;
17+
package org.sonar.plugins.python.telemetry;
1818

1919
import java.util.EnumMap;
2020
import java.util.Map;

python-commons/src/main/java/org/sonar/plugins/python/TelemetryMetricKey.java renamed to python-commons/src/main/java/org/sonar/plugins/python/telemetry/TelemetryMetricKey.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* You should have received a copy of the Sonar Source-Available License
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
17-
package org.sonar.plugins.python;
17+
package org.sonar.plugins.python.telemetry;
1818

1919
public enum TelemetryMetricKey {
2020
NOTEBOOK_PRESENT_KEY("python.notebook.present"),
@@ -47,7 +47,9 @@ public enum TelemetryMetricKey {
4747
PYTHON_TYPES_IMPORTS_TOTAL("python.types.imports.total"),
4848
PYTHON_TYPES_IMPORTS_UNKNOWN("python.types.imports.unknown"),
4949
PYTHON_TYPES_SYMBOLS_UNIQUE("python.types.symbols.unique"),
50-
PYTHON_TYPES_SYMBOLS_UNKNOWN("python.types.symbols.unknown");
50+
PYTHON_TYPES_SYMBOLS_UNKNOWN("python.types.symbols.unknown"),
51+
PYTHON_MAIN_FILES_TOTAL("python.files.main.total"),
52+
PYTHON_MAIN_FILES_MISCLASSIFIED_TEST("python.files.main.misclassified_test");
5153

5254
private final String key;
5355

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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.plugins.python.telemetry.collectors;
18+
19+
/**
20+
* Telemetry data for tracking test file misclassification.
21+
*
22+
* @param totalMainFiles Total number of files classified as MAIN
23+
* @param misclassifiedTestFiles Number of MAIN files that import unittest or pytest (likely test files)
24+
*/
25+
public record TestFileTelemetry(
26+
long totalMainFiles,
27+
long misclassifiedTestFiles) {
28+
29+
public static TestFileTelemetry empty() {
30+
return new TestFileTelemetry(0, 0);
31+
}
32+
33+
public TestFileTelemetry add(TestFileTelemetry other) {
34+
return new TestFileTelemetry(
35+
this.totalMainFiles + other.totalMainFiles,
36+
this.misclassifiedTestFiles + other.misclassifiedTestFiles
37+
);
38+
}
39+
}
40+
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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.plugins.python.telemetry.collectors;
18+
19+
import java.util.Set;
20+
import java.util.concurrent.atomic.AtomicLong;
21+
import org.sonar.api.batch.fs.InputFile;
22+
import org.sonar.plugins.python.api.tree.AssertStatement;
23+
import org.sonar.plugins.python.api.tree.BaseTreeVisitor;
24+
import org.sonar.plugins.python.api.tree.FileInput;
25+
import org.sonar.plugins.python.api.tree.FunctionDef;
26+
import org.sonar.plugins.python.api.tree.ImportFrom;
27+
import org.sonar.plugins.python.api.tree.ImportName;
28+
29+
public class TestFileTelemetryCollector {
30+
31+
private static final Set<String> TEST_FRAMEWORK_MODULES = Set.of("unittest", "pytest");
32+
33+
private final AtomicLong totalMainFiles = new AtomicLong(0);
34+
private final AtomicLong misclassifiedTestFiles = new AtomicLong(0);
35+
36+
public void collect(FileInput rootTree, InputFile.Type fileType) {
37+
if (fileType != InputFile.Type.MAIN) {
38+
return;
39+
}
40+
41+
totalMainFiles.incrementAndGet();
42+
43+
var importVisitor = new TestImportVisitor();
44+
rootTree.accept(importVisitor);
45+
46+
if (importVisitor.hasTestFrameworkImport) {
47+
misclassifiedTestFiles.incrementAndGet();
48+
return;
49+
}
50+
51+
var pytestPatternVisitor = new PytestPatternVisitor();
52+
rootTree.accept(pytestPatternVisitor);
53+
54+
if (pytestPatternVisitor.hasPytestPattern) {
55+
misclassifiedTestFiles.incrementAndGet();
56+
}
57+
}
58+
59+
public TestFileTelemetry getTelemetry() {
60+
return new TestFileTelemetry(totalMainFiles.get(), misclassifiedTestFiles.get());
61+
}
62+
63+
private static class TestImportVisitor extends BaseTreeVisitor {
64+
boolean hasTestFrameworkImport = false;
65+
66+
@Override
67+
public void visitImportName(ImportName importName) {
68+
if (hasTestFrameworkImport) {
69+
return;
70+
}
71+
72+
for (var aliasedName : importName.modules()) {
73+
var names = aliasedName.dottedName().names();
74+
if (!names.isEmpty()) {
75+
String moduleName = names.get(0).name();
76+
if (TEST_FRAMEWORK_MODULES.contains(moduleName)) {
77+
hasTestFrameworkImport = true;
78+
return;
79+
}
80+
}
81+
}
82+
super.visitImportName(importName);
83+
}
84+
85+
@Override
86+
public void visitImportFrom(ImportFrom importFrom) {
87+
if (hasTestFrameworkImport) {
88+
return;
89+
}
90+
91+
var moduleName = importFrom.module();
92+
if (moduleName != null) {
93+
var names = moduleName.names();
94+
if (!names.isEmpty()) {
95+
String rootModule = names.get(0).name();
96+
if (TEST_FRAMEWORK_MODULES.contains(rootModule)) {
97+
hasTestFrameworkImport = true;
98+
return;
99+
}
100+
}
101+
}
102+
super.visitImportFrom(importFrom);
103+
}
104+
}
105+
106+
private static class PytestPatternVisitor extends BaseTreeVisitor {
107+
boolean hasPytestPattern = false;
108+
109+
@Override
110+
public void visitFunctionDef(FunctionDef functionDef) {
111+
if (hasPytestPattern) {
112+
return;
113+
}
114+
115+
String functionName = functionDef.name().name();
116+
if (functionName.startsWith("test_") && containsAssert(functionDef)) {
117+
hasPytestPattern = true;
118+
return;
119+
}
120+
super.visitFunctionDef(functionDef);
121+
}
122+
123+
private static boolean containsAssert(FunctionDef functionDef) {
124+
var assertVisitor = new AssertStatementVisitor();
125+
functionDef.body().accept(assertVisitor);
126+
return assertVisitor.hasAssert;
127+
}
128+
}
129+
130+
private static class AssertStatementVisitor extends BaseTreeVisitor {
131+
boolean hasAssert = false;
132+
133+
@Override
134+
public void visitAssertStatement(AssertStatement assertStatement) {
135+
hasAssert = true;
136+
}
137+
}
138+
}
139+

python-commons/src/main/java/org/sonar/plugins/python/TypeInferenceTelemetry.java renamed to python-commons/src/main/java/org/sonar/plugins/python/telemetry/collectors/TypeInferenceTelemetry.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* You should have received a copy of the Sonar Source-Available License
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
17-
package org.sonar.plugins.python;
17+
package org.sonar.plugins.python.telemetry.collectors;
1818

1919
/**
2020
* Telemetry data for type inference quality metrics.

0 commit comments

Comments
 (0)