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.
@@ -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()