Skip to content

Commit 3a7b928

Browse files
committed
Implement PyLint plugin for Code PushUp
1 parent f812b92 commit 3a7b928

File tree

6 files changed

+5122
-1
lines changed

6 files changed

+5122
-1
lines changed

.github/workflows/code-pushup.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Code PushUp
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
actions: read
12+
pull-requests: write
13+
14+
env:
15+
CP_API_KEY: ${{ secrets.CP_API_KEY }}
16+
17+
jobs:
18+
code_pushup:
19+
runs-on: ubuntu-latest
20+
name: Code PushUp
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v4
24+
- name: Set up Node.js
25+
uses: actions/setup-node@v4
26+
with:
27+
cache: npm
28+
- name: Set up Python
29+
uses: actions/setup-python@v5
30+
with:
31+
python-version: "3.12"
32+
cache: pip
33+
- name: Install PyLint
34+
run: python -m pip install pylint
35+
- name: Install NPM dependencies
36+
run: npm ci
37+
- name: Code PushUp
38+
uses: code-pushup/github-action@v0

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ tests/.coverage*
1717
build/
1818
tests/report/
1919
tests/screenshots/
20+
.code-pushup

code-pushup.config.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { CoreConfig } from "@code-pushup/models";
2+
import pylintPlugin from "./code-pushup.pylint.plugin";
3+
4+
const config: CoreConfig = {
5+
plugins: [await pylintPlugin(["django"])],
6+
categories: [
7+
{
8+
slug: "bug-prevention",
9+
title: "Bug prevention",
10+
refs: [
11+
{
12+
type: "group",
13+
plugin: "pylint",
14+
slug: "error",
15+
weight: 5,
16+
},
17+
{
18+
type: "group",
19+
plugin: "pylint",
20+
slug: "warning",
21+
weight: 1,
22+
},
23+
],
24+
},
25+
{
26+
slug: "code-style",
27+
title: "Code style",
28+
refs: [
29+
{
30+
type: "group",
31+
plugin: "pylint",
32+
slug: "refactor",
33+
weight: 1,
34+
},
35+
{
36+
type: "group",
37+
plugin: "pylint",
38+
slug: "convention",
39+
weight: 1,
40+
},
41+
{
42+
type: "group",
43+
plugin: "pylint",
44+
slug: "info",
45+
weight: 0,
46+
},
47+
],
48+
},
49+
],
50+
...(process.env.CP_API_KEY && {
51+
upload: {
52+
server: "https://api.staging.code-pushup.dev/graphql",
53+
apiKey: process.env.CP_API_KEY,
54+
organization: "code-pushup",
55+
project: "python-example",
56+
},
57+
}),
58+
};
59+
60+
export default config;

code-pushup.pylint.plugin.ts

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
import type {
2+
Audit,
3+
AuditOutput,
4+
Group,
5+
Issue,
6+
IssueSeverity,
7+
PluginConfig,
8+
} from "@code-pushup/models";
9+
import {
10+
capitalize,
11+
compareIssueSeverity,
12+
countOccurrences,
13+
executeProcess,
14+
objectToEntries,
15+
pluralizeToken,
16+
truncateIssueMessage,
17+
} from "@code-pushup/utils";
18+
19+
// FOR FUTURE REFERENCE: PyLint has a default scoring formula:
20+
// 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
21+
// https://pylint.readthedocs.io/en/stable/user_guide/configuration/all-options.html#evaluation
22+
23+
export default async function pylintPlugin(
24+
patterns: string[]
25+
): Promise<PluginConfig> {
26+
const enabledMessages = await findEnabledMessages(patterns);
27+
const audits = listAudits(enabledMessages);
28+
const groups = listGroups(enabledMessages);
29+
30+
return {
31+
slug: "pylint",
32+
title: "PyLint",
33+
icon: "python",
34+
audits,
35+
groups,
36+
runner: () => runLint(patterns, audits),
37+
};
38+
}
39+
40+
type PylintJson2 = {
41+
messages: PylintMessage[];
42+
statistics: PylintStatistics;
43+
};
44+
45+
type PylintMessageType =
46+
| "fatal"
47+
| "error"
48+
| "warning"
49+
| "refactor"
50+
| "convention"
51+
| "info";
52+
53+
type PylintMessage = {
54+
type: PylintMessageType;
55+
symbol: string;
56+
message: string;
57+
messageId: string;
58+
confidence: string;
59+
module: string;
60+
obj: string;
61+
line: number;
62+
column: number;
63+
endLine: number | null;
64+
endColumn: number | null;
65+
path: string;
66+
absolutePath: string;
67+
};
68+
69+
type PylintStatistics = {
70+
messageTypeCount: Record<PylintMessageType, number>;
71+
modulesLinted: number;
72+
score: number;
73+
};
74+
75+
type EnabledMessage = {
76+
symbol: string;
77+
messageId: string;
78+
};
79+
80+
async function findEnabledMessages(
81+
patterns: string[]
82+
): Promise<EnabledMessage[]> {
83+
const { stdout } = await executeProcess({
84+
command: "python",
85+
args: ["-m", "pylint", "--list-msgs-enabled", ...patterns],
86+
});
87+
88+
const lines = stdout.split("\n");
89+
const enabledStart = lines.indexOf("Enabled messages:");
90+
const enabledEnd = lines.findIndex(
91+
(line, i) => i > enabledStart && !line.startsWith(" ")
92+
);
93+
const enabledLines = lines.slice(enabledStart, enabledEnd);
94+
95+
return enabledLines
96+
.map((line): EnabledMessage | null => {
97+
const match = line.match(/^ ([\w-]+) \(([A-Z]\d+)\)$/);
98+
if (!match) {
99+
return null;
100+
}
101+
const [, symbol, messageId] = match;
102+
return { symbol, messageId };
103+
})
104+
.filter((msg): msg is EnabledMessage => msg != null);
105+
}
106+
107+
function listAudits(enabledMessages: EnabledMessage[]): Audit[] {
108+
return enabledMessages.map(({ symbol, messageId }): Audit => {
109+
const type = messageIdToType(messageId);
110+
return {
111+
slug: symbol,
112+
title: `${symbol} (${messageId})`,
113+
...(type && {
114+
docsUrl: `https://pylint.readthedocs.io/en/stable/user_guide/messages/${type}/${symbol}.html`,
115+
}),
116+
};
117+
});
118+
}
119+
120+
function listGroups(enabledMessages: EnabledMessage[]): Group[] {
121+
// source: https://github.com/pylint-dev/pylint/blob/main/pylint/config/help_formatter.py#L47-L53
122+
const descriptions: Record<PylintMessageType, string> = {
123+
info: "for informational messages",
124+
convention: "for programming standard violation",
125+
refactor: "for bad code smell",
126+
warning: "for python specific problems",
127+
error: "for probable bugs in the code",
128+
fatal: "if an error occurred which prevented pylint from doing further processing",
129+
};
130+
131+
const categoriesMap = enabledMessages.reduce<Record<string, string[]>>(
132+
(acc, { symbol, messageId }) => {
133+
const type = messageIdToType(messageId);
134+
if (!type) {
135+
return acc;
136+
}
137+
return { ...acc, [type]: [...(acc[type] ?? []), symbol] };
138+
},
139+
{}
140+
);
141+
return Object.entries(categoriesMap).map(
142+
([type, symbols]): Group => ({
143+
slug: type,
144+
title: capitalize(type),
145+
description: descriptions[type],
146+
docsUrl: `https://pylint.readthedocs.io/en/stable/user_guide/messages/messages_overview.html#${type}`,
147+
refs: symbols.map((symbol) => ({ slug: symbol, weight: 1 })),
148+
})
149+
);
150+
}
151+
152+
function messageIdToType(messageId: string): PylintMessageType | null {
153+
switch (messageId[0]) {
154+
case "F":
155+
return "fatal";
156+
case "E":
157+
return "error";
158+
case "W":
159+
return "warning";
160+
case "R":
161+
return "refactor";
162+
case "C":
163+
return "convention";
164+
case "I":
165+
return "info";
166+
default:
167+
return null;
168+
}
169+
}
170+
171+
async function runLint(
172+
patterns: string[],
173+
audits: Audit[]
174+
): Promise<AuditOutput[]> {
175+
const { stdout, stderr } = await executeProcess({
176+
command: "python",
177+
args: ["-m", "pylint", "--output-format=json2", ...patterns],
178+
ignoreExitCode: true,
179+
});
180+
181+
if (stderr) {
182+
throw new Error(stderr);
183+
}
184+
185+
const result = JSON.parse(stdout) as PylintJson2;
186+
187+
const issuesMap = result.messages.reduce<Record<string, Issue[]>>(
188+
(acc, message) => ({
189+
...acc,
190+
[message.symbol]: [
191+
...(acc[message.symbol] ?? []),
192+
messageToIssue(message),
193+
],
194+
}),
195+
{}
196+
);
197+
198+
return audits.map(({ slug }): AuditOutput => {
199+
const issues = issuesMap[slug] ?? [];
200+
201+
const severityCounts = countOccurrences(
202+
issues.map(({ severity }) => severity)
203+
);
204+
const severities = objectToEntries(severityCounts);
205+
const summaryText =
206+
[...severities]
207+
.sort((a, b) => -compareIssueSeverity(a[0], b[0]))
208+
.map(([severity, count = 0]) => pluralizeToken(severity, count))
209+
.join(", ") || "passed";
210+
211+
return {
212+
slug,
213+
score: Number(issues.length === 0),
214+
value: issues.length,
215+
displayValue: summaryText,
216+
details: { issues },
217+
};
218+
});
219+
}
220+
221+
function messageToIssue({
222+
type,
223+
message,
224+
path,
225+
line,
226+
column,
227+
endLine,
228+
endColumn,
229+
}: PylintMessage): Issue {
230+
return {
231+
message: truncateIssueMessage(message.replace(/_/g, "\\_")),
232+
severity: messageTypeToSeverity(type),
233+
source: {
234+
file: path,
235+
position: {
236+
startLine: line,
237+
startColumn: column + 1,
238+
...(endLine != null && { endLine }),
239+
...(endColumn != null && { endColumn: endColumn + 1 }),
240+
},
241+
},
242+
};
243+
}
244+
245+
function messageTypeToSeverity(type: PylintMessageType): IssueSeverity {
246+
switch (type) {
247+
case "fatal":
248+
case "error":
249+
return "error";
250+
case "warning":
251+
return "warning";
252+
case "refactor":
253+
case "convention":
254+
case "info":
255+
return "info";
256+
}
257+
}

0 commit comments

Comments
 (0)