Skip to content

Commit 42e9ddf

Browse files
SONARPY-1233 Allow import of mypy reports (#1431)
1 parent a57fc54 commit 42e9ddf

File tree

18 files changed

+916
-1
lines changed

18 files changed

+916
-1
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
src/type_hints_noncompliant.py:11:11: error: Argument 1 to "greet_all" has incompatible type "List[int]"; expected "List[str]" [arg-type]
2+
src/type_hints_noncompliant.py:13:1: error: Function is missing a type annotation [no-untyped-def]
3+
src/type_hints_noncompliant.py:16:1: error: Cannot find implementation or library stub for module named "unknown" [import]
4+
src/type_hints_noncompliant.py:16:1: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
5+
src/type_hints_noncompliant.py:19:11: error: Call to untyped function "no_type_hints" in typed context [no-untyped-call]
6+
src/type_hints_noncompliant.py:24: error: Unused "type: ignore" comment
7+
Found 5 errors in 1 file (checked 1 source file)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Required metadata
2+
sonar.projectKey=mypy_project
3+
sonar.projectName=mypy_project
4+
sonar.projectVersion=1
5+
sonar.sourceEncoding=UTF8
6+
7+
# Comma-separated paths to directories with sources (required)
8+
sonar.sources=src
9+
sonar.python.mypy.reportPaths=mypy_output.txt
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Example from the mypy documentation
2+
3+
def greet_all(names: list[str]) -> None:
4+
for name in names:
5+
print('Hello ' + name)
6+
7+
names = ["Alice", "Bob", "Charlie"]
8+
ages = [10, 20, 30]
9+
10+
greet_all(names)
11+
greet_all(ages)
12+
13+
def no_type_hints(x):
14+
return [x]
15+
16+
from unknown import unknown
17+
18+
if __name__ == "__name__":
19+
print(no_type_hints(0))
20+
greet_all(unknown())
21+
22+
from typing import List
23+
24+
def ignore_comment() -> List[str]: # type: ignore
25+
return []
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2012-2023 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 GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package com.sonar.python.it.plugin;
21+
22+
import com.sonar.orchestrator.Orchestrator;
23+
import com.sonar.orchestrator.build.SonarScanner;
24+
import java.io.File;
25+
import java.util.List;
26+
import org.junit.ClassRule;
27+
import org.junit.Test;
28+
import org.sonarqube.ws.Common;
29+
import org.sonarqube.ws.Issues;
30+
31+
import static com.sonar.python.it.plugin.Tests.issues;
32+
import static org.assertj.core.api.Assertions.assertThat;
33+
34+
public class MypyReportTest {
35+
36+
private static final String PROJECT = "mypy_project";
37+
38+
@ClassRule
39+
public static final Orchestrator ORCHESTRATOR = Tests.ORCHESTRATOR;
40+
41+
@Test
42+
public void import_report() {
43+
ORCHESTRATOR.getServer().provisionProject(PROJECT, PROJECT);
44+
ORCHESTRATOR.getServer().associateProjectToQualityProfile(PROJECT, "py", "no_rule");
45+
ORCHESTRATOR.executeBuild(
46+
SonarScanner.create()
47+
.setProjectDir(new File("projects/mypy_project")));
48+
49+
List<Issues.Issue> issues = issues(PROJECT);
50+
assertThat(issues).hasSize(5);
51+
assertIssue(issues.get(0), "external_mypy:arg-type", "Argument 1 to \"greet_all\" has incompatible type \"List[int]\"; expected \"List[str]\"");
52+
assertIssue(issues.get(1), "external_mypy:no-untyped-def", "Function is missing a type annotation");
53+
assertIssue(issues.get(2), "external_mypy:import", "Cannot find implementation or library stub for module named \"unknown\"");
54+
assertIssue(issues.get(3), "external_mypy:no-untyped-call", "Call to untyped function \"no_type_hints\" in typed context");
55+
assertIssue(issues.get(4), "external_mypy:????", "Unused \"type: ignore\" comment");
56+
}
57+
58+
private static void assertIssue(Issues.Issue issue, String rule, String message) {
59+
assertThat(issue.getComponent()).isEqualTo("mypy_project:src/type_hints_noncompliant.py");
60+
assertThat(issue.getRule()).isEqualTo(rule);
61+
assertThat(issue.getMessage()).isEqualTo(message);
62+
assertThat(issue.getType()).isEqualTo(Common.RuleType.CODE_SMELL);
63+
assertThat(issue.getSeverity()).isEqualTo(Common.Severity.MAJOR);
64+
assertThat(issue.getEffort()).isEqualTo("5min");
65+
}
66+
}

sonar-python-plugin/src/main/java/org/sonar/plugins/python/PythonPlugin.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
import org.sonar.plugins.python.flake8.Flake8RulesDefinition;
3434
import org.sonar.plugins.python.flake8.Flake8Sensor;
3535
import org.sonar.plugins.python.indexer.SonarLintPythonIndexer;
36+
import org.sonar.plugins.python.mypy.MypyRulesDefinition;
37+
import org.sonar.plugins.python.mypy.MypySensor;
3638
import org.sonar.plugins.python.pylint.PylintRulesDefinition;
3739
import org.sonar.plugins.python.pylint.PylintSensor;
3840
import org.sonar.plugins.python.warnings.AnalysisWarningsWrapper;
@@ -82,6 +84,7 @@ public void define(Context context) {
8284
addPylintExtensions(context);
8385
addBanditExtensions(context);
8486
addFlake8Extensions(context);
87+
addMypyExtensions(context);
8588
}
8689
if (sonarRuntime.getProduct() == SonarProduct.SONARLINT) {
8790
SonarLintPluginAPIManager sonarLintPluginAPIManager = new SonarLintPluginAPIManager();
@@ -184,6 +187,19 @@ private static void addFlake8Extensions(Context context) {
184187
Flake8RulesDefinition.class);
185188
}
186189

190+
private static void addMypyExtensions(Context context) {
191+
context.addExtensions(MypySensor.class,
192+
PropertyDefinition.builder(MypySensor.REPORT_PATH_KEY)
193+
.name("Mypy Report Files")
194+
.description("Paths (absolute or relative) to report files with Mypy issues.")
195+
.category(EXTERNAL_ANALYZERS_CATEGORY)
196+
.subCategory(PYTHON_CATEGORY)
197+
.onQualifiers(Qualifiers.PROJECT)
198+
.multiValues(true)
199+
.build(),
200+
MypyRulesDefinition.class);
201+
}
202+
187203
static class SonarLintPluginAPIManager {
188204

189205
public void addSonarlintPythonIndexer(Context context, SonarLintPluginAPIVersion sonarLintPluginAPIVersion) {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2023 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 GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.plugins.python.mypy;
21+
22+
import org.sonar.api.server.rule.RulesDefinition;
23+
import org.sonar.plugins.python.Python;
24+
import org.sonarsource.analyzer.commons.ExternalRuleLoader;
25+
26+
public class MypyRulesDefinition implements RulesDefinition {
27+
28+
private static final String RULES_JSON = "org/sonar/plugins/python/mypy/rules.json";
29+
30+
private static final ExternalRuleLoader RULE_LOADER = new ExternalRuleLoader(MypySensor.LINTER_KEY, MypySensor.LINTER_NAME, RULES_JSON, Python.KEY);
31+
32+
@Override
33+
public void define(RulesDefinition.Context context) {
34+
RULE_LOADER.createExternalRuleRepository(context);
35+
}
36+
37+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* SonarQube Python Plugin
3+
* Copyright (C) 2011-2023 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 GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
package org.sonar.plugins.python.mypy;
21+
22+
import java.io.File;
23+
import java.io.IOException;
24+
import java.util.ArrayList;
25+
import java.util.List;
26+
import java.util.Optional;
27+
import java.util.Scanner;
28+
import java.util.Set;
29+
import java.util.regex.Matcher;
30+
import java.util.regex.Pattern;
31+
import org.sonar.api.batch.fs.FileSystem;
32+
import org.sonar.api.batch.sensor.SensorContext;
33+
import org.sonar.api.config.Configuration;
34+
import org.sonar.api.utils.log.Logger;
35+
import org.sonar.api.utils.log.Loggers;
36+
import org.sonar.plugins.python.ExternalIssuesSensor;
37+
import org.sonar.plugins.python.TextReportReader;
38+
39+
public class MypySensor extends ExternalIssuesSensor {
40+
41+
private static final Logger LOG = Loggers.get(MypySensor.class);
42+
43+
public static final String LINTER_NAME = "Mypy";
44+
public static final String LINTER_KEY = "mypy";
45+
public static final String REPORT_PATH_KEY = "sonar.python.mypy.reportPaths";
46+
47+
private static final String FALLBACK_RULE_KEY = "unknown_mypy_rule";
48+
49+
// Pattern -> Location ': ' Severity ':' Message '['Code']'
50+
// Location -> File ':' StartLine
51+
private static final Pattern PATTERN =
52+
Pattern.compile("^(?<file>[^:]+):(?<startLine>\\d+)(?::(?<startCol>\\d+))?(?::\\d+:\\d+)?: (?<severity>\\S+[^:]): (?<message>.*?)(?: \\[(?<code>.*)])?\\s*$");
53+
54+
@Override
55+
protected void importReport(File reportPath, SensorContext context, Set<String> unresolvedInputFiles) throws IOException {
56+
List<TextReportReader.Issue> issues = parse(reportPath, context.fileSystem());
57+
issues.forEach(i -> saveIssue(context, i, unresolvedInputFiles, LINTER_KEY));
58+
}
59+
60+
private static List<TextReportReader.Issue> parse(File report, FileSystem fileSystem) throws IOException {
61+
List<TextReportReader.Issue> issues = new ArrayList<>();
62+
try (Scanner scanner = new Scanner(report.toPath(), fileSystem.encoding().name())) {
63+
while (scanner.hasNextLine()) {
64+
TextReportReader.Issue issue = parseLine(scanner.nextLine());
65+
if (issue != null) {
66+
issues.add(issue);
67+
}
68+
}
69+
}
70+
return issues;
71+
}
72+
73+
private static TextReportReader.Issue parseLine(String line) {
74+
if (line.length() > 0) {
75+
Matcher m = PATTERN.matcher(line);
76+
if (m.matches()) {
77+
return extractIssue(m);
78+
}
79+
LOG.debug("Cannot parse the line: {}", line);
80+
}
81+
82+
return null;
83+
}
84+
85+
private static TextReportReader.Issue extractIssue(Matcher m) {
86+
String severity = m.group("severity");
87+
if (!"error".equals(severity)) {
88+
return null;
89+
}
90+
91+
String filePath = m.group("file");
92+
int lineNumber = Integer.parseInt(m.group("startLine"));
93+
String message = m.group("message");
94+
String errorCode = m.group("code");
95+
if (errorCode == null) {
96+
// Sometimes mypy does not report an error code, however the API expects a non-null error code.
97+
errorCode = FALLBACK_RULE_KEY;
98+
}
99+
100+
Integer columnNumber = Optional.ofNullable(m.group("startCol"))
101+
.map(Integer::parseInt)
102+
.map(i -> i - 1)
103+
.orElse(null);
104+
105+
return new TextReportReader.Issue(filePath, errorCode, message, lineNumber, columnNumber);
106+
}
107+
108+
@Override
109+
protected boolean shouldExecute(Configuration conf) {
110+
return conf.hasKey(REPORT_PATH_KEY);
111+
}
112+
113+
@Override
114+
protected String linterName() {
115+
return LINTER_NAME;
116+
}
117+
118+
@Override
119+
protected String reportPathKey() {
120+
return REPORT_PATH_KEY;
121+
}
122+
123+
@Override
124+
protected Logger logger() {
125+
return LOG;
126+
}
127+
}

0 commit comments

Comments
 (0)