python-linters 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- python_linters/__init__.py +0 -0
- python_linters/__main__.py +11 -0
- python_linters/cli_commands.py +103 -0
- python_linters/config_check.py +413 -0
- python_linters/lint_report.py +273 -0
- python_linters/reference_files.py +25 -0
- python_linters/references/ruff.reference.toml +505 -0
- python_linters/references/ty.reference.toml +203 -0
- python_linters/tool_errors.py +44 -0
- python_linters-0.2.0.dist-info/METADATA +44 -0
- python_linters-0.2.0.dist-info/RECORD +13 -0
- python_linters-0.2.0.dist-info/WHEEL +4 -0
- python_linters-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Run ruff, ty and pymodularity and group their findings by rule.
|
|
2
|
+
|
|
3
|
+
Ported from lint_grouped.py (quality-and-security-tools howtos), minus eslint.
|
|
4
|
+
|
|
5
|
+
Output structure:
|
|
6
|
+
{
|
|
7
|
+
"project": "/abs/path",
|
|
8
|
+
"summary": {
|
|
9
|
+
"ruff": {"total": 100, "rules": 12},
|
|
10
|
+
"ty": {"total": 50, "rules": 8},
|
|
11
|
+
"pymodularity": {"total": 5, "rules": 2}
|
|
12
|
+
},
|
|
13
|
+
"ruff": {
|
|
14
|
+
"ANN001": {
|
|
15
|
+
"url": "https://docs.astral.sh/ruff/rules/...",
|
|
16
|
+
"count": 42,
|
|
17
|
+
"locations": [{"file": "rel/path.py", "line": 12, "col": 5, "message": "..."}, ...]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"ty": {
|
|
21
|
+
"invalid-return-type": {
|
|
22
|
+
"severity": "major",
|
|
23
|
+
"count": 15,
|
|
24
|
+
"locations": [{"file": "rel/path.py", "line": 7, "col": 1, "message": "..."}, ...]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"pymodularity": {
|
|
28
|
+
"import-cycle": {
|
|
29
|
+
"url": "https://.../rules.md#...",
|
|
30
|
+
"count": 3,
|
|
31
|
+
"locations": [{"file": "rel/path.py", "line": 0, "col": 0, "message": "..."}, ...]
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Rules are sorted by count (descending) so the most common issues come first.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
from dataclasses import dataclass, field
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import TYPE_CHECKING
|
|
47
|
+
|
|
48
|
+
from python_linters.tool_errors import run_checked
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from collections.abc import Iterable
|
|
52
|
+
|
|
53
|
+
_TY_SEVERITY_MAP = {"major": "error", "minor": "warning", "info": "info"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Location:
|
|
58
|
+
file: str
|
|
59
|
+
line: int
|
|
60
|
+
col: int
|
|
61
|
+
message: str
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> dict[str, object]:
|
|
64
|
+
return {"file": self.file, "line": self.line, "col": self.col, "message": self.message}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class FindingGroup:
|
|
69
|
+
"""One rule's findings: ruff/pymodularity carry a `url`, ty a `severity`."""
|
|
70
|
+
|
|
71
|
+
url: str | None = None
|
|
72
|
+
severity: str | None = None
|
|
73
|
+
count: int = 0
|
|
74
|
+
locations: list[Location] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, object]:
|
|
77
|
+
out: dict[str, object] = {}
|
|
78
|
+
if self.url is not None:
|
|
79
|
+
out["url"] = self.url
|
|
80
|
+
if self.severity is not None:
|
|
81
|
+
out["severity"] = self.severity
|
|
82
|
+
out["count"] = self.count
|
|
83
|
+
out["locations"] = [location.to_dict() for location in self.locations]
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# A finding on its way into a rule group: (rule key, its url or None, its
|
|
88
|
+
# severity or None, the one location this finding adds).
|
|
89
|
+
_Finding = tuple[str, str | None, str | None, Location]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _group_findings(findings: Iterable[_Finding]) -> dict[str, FindingGroup]:
|
|
93
|
+
"""Group findings by rule, each group carrying a count and its locations.
|
|
94
|
+
|
|
95
|
+
The one grouping loop shared by ruff, ty and pymodularity — each tool
|
|
96
|
+
only maps its own diagnostics into `_Finding` tuples first.
|
|
97
|
+
"""
|
|
98
|
+
grouped: dict[str, FindingGroup] = {}
|
|
99
|
+
for rule, url, severity, location in findings:
|
|
100
|
+
group = grouped.setdefault(rule, FindingGroup(url=url, severity=severity))
|
|
101
|
+
group.count += 1
|
|
102
|
+
group.locations.append(location)
|
|
103
|
+
|
|
104
|
+
return dict(sorted(grouped.items(), key=lambda item: item[1].count, reverse=True))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _relative_to(path_str: str, project_path: Path) -> str:
|
|
108
|
+
try:
|
|
109
|
+
return str(Path(path_str).relative_to(project_path))
|
|
110
|
+
except ValueError:
|
|
111
|
+
return path_str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_ruff(project_path: Path) -> dict[str, FindingGroup]:
|
|
115
|
+
# Resolution: the python-linters environment's own pinned ruff, run as
|
|
116
|
+
# `sys.executable -m ruff` so it never falls through to PATH. In
|
|
117
|
+
# dev-dependency mode that environment is the linted project's venv, so
|
|
118
|
+
# ruff==0.17.3 (pinned in python-linters' own pyproject.toml) is what
|
|
119
|
+
# actually runs — never a PATH ruff of another version, which lints
|
|
120
|
+
# with other rules and may refuse the reference config.
|
|
121
|
+
#
|
|
122
|
+
# ruff's exit codes: 0 clean, 1 findings, 2 tool failure (unknown rule
|
|
123
|
+
# selector, bad config, ...). Empty stdout on exit 2 must never read
|
|
124
|
+
# as "0 findings" — `run_checked` raises instead of returning in that case.
|
|
125
|
+
result = run_checked(
|
|
126
|
+
[sys.executable, "-m", "ruff", "check", "--no-cache", "--output-format", "json", "."],
|
|
127
|
+
project_path,
|
|
128
|
+
"ruff",
|
|
129
|
+
ok_codes=(0, 1),
|
|
130
|
+
)
|
|
131
|
+
if not result.stdout.strip():
|
|
132
|
+
return {}
|
|
133
|
+
|
|
134
|
+
diagnostics = json.loads(result.stdout)
|
|
135
|
+
|
|
136
|
+
def findings() -> Iterable[_Finding]:
|
|
137
|
+
for d in diagnostics:
|
|
138
|
+
code = d.get("code") or "unknown"
|
|
139
|
+
location = Location(
|
|
140
|
+
file=_relative_to(d["filename"], project_path),
|
|
141
|
+
line=d["location"]["row"],
|
|
142
|
+
col=d["location"]["column"],
|
|
143
|
+
message=d["message"],
|
|
144
|
+
)
|
|
145
|
+
yield code, d.get("url", ""), None, location
|
|
146
|
+
|
|
147
|
+
return _group_findings(findings())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_ty(project_path: Path) -> dict[str, FindingGroup]:
|
|
151
|
+
# Resolution: `sys.executable -m ty` from the python-linters
|
|
152
|
+
# environment, never a bare `ty` entry point (that one falls through to
|
|
153
|
+
# PATH when the environment has no ty on it) and never a PATH `ty`.
|
|
154
|
+
# In dev-dependency mode that environment is the linted project's own
|
|
155
|
+
# venv, so ty also sees the project's installed packages for
|
|
156
|
+
# resolution.
|
|
157
|
+
#
|
|
158
|
+
# Output format: `gitlab` — the only ty format that is both
|
|
159
|
+
# machine-readable JSON and carries rule id, severity, file, line and
|
|
160
|
+
# column, which is what the merged-JSON shape below needs. (`json` does
|
|
161
|
+
# not exist as a ty output format; `full`/`concise` are human text.)
|
|
162
|
+
#
|
|
163
|
+
# ty's own exit codes: 0 clean, 1 findings, 2 tool failure (bad config,
|
|
164
|
+
# crash, ...). A failure must never read as "0 findings" — that reversed
|
|
165
|
+
# a `ty` config bug into a silent clean bill in review.
|
|
166
|
+
#
|
|
167
|
+
# Environment: `uv run` sets VIRTUAL_ENV to python-linters' own venv,
|
|
168
|
+
# and ty's environment discovery reads VIRTUAL_ENV *before* looking for
|
|
169
|
+
# a `.venv` under cwd — unlike `sys.executable`, picking the wrong env
|
|
170
|
+
# doesn't depend on which env actually ran this process. Left alone,
|
|
171
|
+
# ty resolves the linted project's imports (e.g. `import attrs`)
|
|
172
|
+
# against python-linters' own env instead of the project's, and
|
|
173
|
+
# reports them as unresolved-import false positives. Dropping
|
|
174
|
+
# VIRTUAL_ENV here makes ty fall through to discovering `.venv` under
|
|
175
|
+
# project_path itself. In dev-dependency mode this is a no-op: there
|
|
176
|
+
# the project's own venv was VIRTUAL_ENV already.
|
|
177
|
+
ty_env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
|
|
178
|
+
result = run_checked(
|
|
179
|
+
[sys.executable, "-m", "ty", "check", "--output-format", "gitlab", "."],
|
|
180
|
+
project_path,
|
|
181
|
+
"ty",
|
|
182
|
+
ok_codes=(0, 1),
|
|
183
|
+
env=ty_env,
|
|
184
|
+
)
|
|
185
|
+
if not result.stdout.strip():
|
|
186
|
+
return {}
|
|
187
|
+
|
|
188
|
+
diagnostics = json.loads(result.stdout)
|
|
189
|
+
|
|
190
|
+
def findings() -> Iterable[_Finding]:
|
|
191
|
+
# GitLab labels severities major/minor/info; ty itself says
|
|
192
|
+
# error/warning/info. Map back so a reader sees the same words
|
|
193
|
+
# `ty check` would have printed.
|
|
194
|
+
for d in diagnostics:
|
|
195
|
+
rule = d.get("check_name") or "unknown"
|
|
196
|
+
|
|
197
|
+
location_data = d.get("location", {})
|
|
198
|
+
start = location_data.get("positions", {}).get("begin", {})
|
|
199
|
+
|
|
200
|
+
# GitLab's description already starts with "<check_name>: ",
|
|
201
|
+
# which would otherwise repeat the rule id inside every message.
|
|
202
|
+
description = d.get("description", "")
|
|
203
|
+
description = description.removeprefix(f"{rule}: ")
|
|
204
|
+
|
|
205
|
+
severity = _TY_SEVERITY_MAP.get(d.get("severity", "major"), "error")
|
|
206
|
+
location = Location(
|
|
207
|
+
# GitLab paths are already relative to cwd (= project_path,
|
|
208
|
+
# since ty runs with cwd=project_path), so no relative_to()
|
|
209
|
+
# call is needed here.
|
|
210
|
+
file=location_data.get("path", ""),
|
|
211
|
+
line=start.get("line", 1),
|
|
212
|
+
col=start.get("column", 1),
|
|
213
|
+
message=description,
|
|
214
|
+
)
|
|
215
|
+
yield rule, None, severity, location
|
|
216
|
+
|
|
217
|
+
return _group_findings(findings())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_pymodularity(project_path: Path) -> dict[str, FindingGroup]:
|
|
221
|
+
# Resolution: `sys.executable -m pymodularity`, same reasoning as ruff
|
|
222
|
+
# and ty above — the python-linters environment's own pinned install,
|
|
223
|
+
# never PATH.
|
|
224
|
+
#
|
|
225
|
+
# pymodularity's exit codes: 0 empty array, 1 at least one finding,
|
|
226
|
+
# 2 `uv` missing or an uncaught exception (docs/rules.md "JSON output").
|
|
227
|
+
# A failure must never read as "0 findings".
|
|
228
|
+
result = run_checked(
|
|
229
|
+
[sys.executable, "-m", "pymodularity", "check", "--output-format", "json",
|
|
230
|
+
str(project_path)],
|
|
231
|
+
project_path,
|
|
232
|
+
"pymodularity",
|
|
233
|
+
ok_codes=(0, 1),
|
|
234
|
+
)
|
|
235
|
+
if not result.stdout.strip():
|
|
236
|
+
return {}
|
|
237
|
+
|
|
238
|
+
findings_data = json.loads(result.stdout)
|
|
239
|
+
|
|
240
|
+
def findings() -> Iterable[_Finding]:
|
|
241
|
+
for f in findings_data:
|
|
242
|
+
rule = f.get("rule") or "unknown"
|
|
243
|
+
# pymodularity's `path` key maps to `file` here, to match the
|
|
244
|
+
# ruff and ty groups above.
|
|
245
|
+
location = Location(
|
|
246
|
+
file=f.get("path", ""),
|
|
247
|
+
line=f.get("line", 0),
|
|
248
|
+
col=f.get("col", 0),
|
|
249
|
+
message=f.get("message", ""),
|
|
250
|
+
)
|
|
251
|
+
yield rule, f.get("url", ""), None, location
|
|
252
|
+
|
|
253
|
+
return _group_findings(findings())
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def build_report(project_path: Path) -> tuple[dict[str, object], int]:
|
|
257
|
+
"""Return the JSON-ready report and the total finding count across all tools."""
|
|
258
|
+
summary: dict[str, object] = {}
|
|
259
|
+
sections: dict[str, object] = {}
|
|
260
|
+
grand_total = 0
|
|
261
|
+
|
|
262
|
+
for tool, run in (("ruff", run_ruff), ("ty", run_ty), ("pymodularity", run_pymodularity)):
|
|
263
|
+
print(f"Running {tool} on {project_path} ...", file=sys.stderr)
|
|
264
|
+
violations = run(project_path)
|
|
265
|
+
total = sum(group.count for group in violations.values())
|
|
266
|
+
print(f" {total} violations across {len(violations)} rules", file=sys.stderr)
|
|
267
|
+
|
|
268
|
+
summary[tool] = {"total": total, "rules": len(violations)}
|
|
269
|
+
sections[tool] = {rule: group.to_dict() for rule, group in violations.items()}
|
|
270
|
+
grand_total += total
|
|
271
|
+
|
|
272
|
+
report = {"project": str(project_path), "summary": summary, **sections}
|
|
273
|
+
return report, grand_total
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Print a shipped reference config via importlib.resources.
|
|
2
|
+
|
|
3
|
+
The package never writes a reference into the linted project — the user
|
|
4
|
+
copies it, per the brief's "copying stays with the user".
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from importlib import resources
|
|
10
|
+
|
|
11
|
+
_REFERENCE_FILENAMES = {
|
|
12
|
+
"ruff": "ruff.reference.toml",
|
|
13
|
+
"ty": "ty.reference.toml",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def reference_text(tool: str) -> str:
|
|
18
|
+
"""Return a shipped reference config's full text.
|
|
19
|
+
|
|
20
|
+
Raises KeyError for a tool with no shipped reference — the CLI turns
|
|
21
|
+
that into argparse's own `choices=` error instead of calling this with
|
|
22
|
+
an unknown tool.
|
|
23
|
+
"""
|
|
24
|
+
filename = _REFERENCE_FILENAMES[tool]
|
|
25
|
+
return resources.files("python_linters.references").joinpath(filename).read_text()
|