python-linters 0.2.1__py3-none-any.whl → 0.2.3__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/baseline_report.py +199 -0
- python_linters/cli_commands.py +72 -5
- python_linters/lint_report.py +192 -15
- python_linters/references/ruff.reference.toml +16 -10
- python_linters/references/ty.reference.toml +31 -19
- python_linters/text_report.py +54 -0
- python_linters/tool_versions.py +22 -0
- python_linters-0.2.3.dist-info/METADATA +105 -0
- python_linters-0.2.3.dist-info/RECORD +16 -0
- {python_linters-0.2.1.dist-info → python_linters-0.2.3.dist-info}/WHEEL +1 -1
- python_linters-0.2.1.dist-info/METADATA +0 -44
- python_linters-0.2.1.dist-info/RECORD +0 -13
- {python_linters-0.2.1.dist-info → python_linters-0.2.3.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""`lint --baseline REF`: report only findings new since a git ref.
|
|
2
|
+
|
|
3
|
+
Ported from the baseline mode of the semgrep-wrapping gate script, minus
|
|
4
|
+
semgrep.
|
|
5
|
+
|
|
6
|
+
Mechanism:
|
|
7
|
+
1. Resolve *ref* to a SHA with `git rev-parse --verify` (a bad ref, or a
|
|
8
|
+
project outside any git repo, fails here).
|
|
9
|
+
2. Lint the current tree first, so its resolved ty environment is known.
|
|
10
|
+
3. Check out that SHA into a fresh `git worktree add --detach` scratch
|
|
11
|
+
directory — never a reused directory, which can carry stale files from
|
|
12
|
+
an earlier run.
|
|
13
|
+
4. Lint the same sub-path inside the worktree, with the same `--skip` set.
|
|
14
|
+
ty's `.venv` discovery has nothing to find in a fresh worktree, so the
|
|
15
|
+
baseline run gets `--python` forwarded explicitly: the caller's own
|
|
16
|
+
`--python` if given, otherwise the current run's resolved
|
|
17
|
+
`ty-environment.python_environment`.
|
|
18
|
+
5. Drop every current finding whose (file, rule, message) triple also
|
|
19
|
+
appears at the baseline. The line number is excluded from this key on
|
|
20
|
+
purpose: a finding does not become "new" just because a line was
|
|
21
|
+
inserted above it in the diff's range.
|
|
22
|
+
6. Remove the worktree in `finally`, even on a lint failure or a Ctrl-C.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import tempfile
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
from python_linters.lint_report import TOOL_NAMES, build_report
|
|
33
|
+
|
|
34
|
+
# Resolved once to a full path: a bare "git" is a partial-executable-path
|
|
35
|
+
# finding, and the fallback keeps this working even if `which` ever fails.
|
|
36
|
+
_GIT = shutil.which("git") or "git"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaselineError(Exception):
|
|
40
|
+
"""A baseline ref could not be resolved, or project_path is not in a git repo."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _resolve_ref(project_path: Path, ref: str) -> str:
|
|
44
|
+
result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # fixed argv list built by this package, never shell-interpolated or from untrusted input
|
|
45
|
+
[_GIT, "-C", str(project_path), "rev-parse", "--verify", ref],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
text=True,
|
|
48
|
+
check=False,
|
|
49
|
+
)
|
|
50
|
+
if result.returncode != 0:
|
|
51
|
+
msg = f"cannot resolve baseline ref '{ref}': {result.stderr.strip()}"
|
|
52
|
+
raise BaselineError(msg)
|
|
53
|
+
return result.stdout.strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git_root(project_path: Path) -> Path:
|
|
57
|
+
result = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # fixed argv list built by this package, never shell-interpolated or from untrusted input
|
|
58
|
+
[_GIT, "-C", str(project_path), "rev-parse", "--show-toplevel"],
|
|
59
|
+
capture_output=True,
|
|
60
|
+
text=True,
|
|
61
|
+
check=False,
|
|
62
|
+
)
|
|
63
|
+
return Path(result.stdout.strip())
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _triple(file: str, rule: str, message: str) -> tuple[str, str, str]:
|
|
67
|
+
return (file, rule, message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _baseline_triples(report: dict[str, object]) -> set[tuple[str, str, str]]:
|
|
71
|
+
triples: set[tuple[str, str, str]] = set()
|
|
72
|
+
for tool in TOOL_NAMES:
|
|
73
|
+
section = report.get(tool)
|
|
74
|
+
if not isinstance(section, dict):
|
|
75
|
+
continue
|
|
76
|
+
for rule, group in section.items():
|
|
77
|
+
triples.update(
|
|
78
|
+
_triple(loc["file"], rule, loc["message"]) for loc in group["locations"]
|
|
79
|
+
)
|
|
80
|
+
return triples
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _diff_report(
|
|
84
|
+
report: dict[str, object], baseline_triples: set[tuple[str, str, str]]
|
|
85
|
+
) -> tuple[dict[str, object], int]:
|
|
86
|
+
"""Keep only locations whose triple is not in *baseline_triples*.
|
|
87
|
+
|
|
88
|
+
Recomputes each kept rule's `count` and each tool's `summary` entry;
|
|
89
|
+
drops a rule whose locations list becomes empty.
|
|
90
|
+
"""
|
|
91
|
+
new_report = dict(report)
|
|
92
|
+
summary: dict[str, object] = {}
|
|
93
|
+
grand_total = 0
|
|
94
|
+
|
|
95
|
+
for tool in TOOL_NAMES:
|
|
96
|
+
section = report.get(tool)
|
|
97
|
+
if not isinstance(section, dict):
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
new_section: dict[str, dict[str, object]] = {}
|
|
101
|
+
counts: dict[str, int] = {}
|
|
102
|
+
for rule, group in section.items():
|
|
103
|
+
keep = [
|
|
104
|
+
loc
|
|
105
|
+
for loc in group["locations"]
|
|
106
|
+
if _triple(loc["file"], rule, loc["message"]) not in baseline_triples
|
|
107
|
+
]
|
|
108
|
+
if not keep:
|
|
109
|
+
continue
|
|
110
|
+
new_group = dict(group)
|
|
111
|
+
new_group["locations"] = keep
|
|
112
|
+
new_group["count"] = len(keep)
|
|
113
|
+
new_section[rule] = new_group
|
|
114
|
+
counts[rule] = len(keep)
|
|
115
|
+
|
|
116
|
+
new_report[tool] = new_section
|
|
117
|
+
total = sum(counts.values())
|
|
118
|
+
summary[tool] = {"total": total, "rules": len(new_section)}
|
|
119
|
+
grand_total += total
|
|
120
|
+
|
|
121
|
+
new_report["summary"] = summary
|
|
122
|
+
return new_report, grand_total
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def run_with_baseline(
|
|
126
|
+
project_path: Path,
|
|
127
|
+
baseline_ref: str,
|
|
128
|
+
skip: frozenset[str] = frozenset(),
|
|
129
|
+
ty_python: str | None = None,
|
|
130
|
+
ty_extra_search_paths: tuple[str, ...] = (),
|
|
131
|
+
) -> tuple[dict[str, object], int]:
|
|
132
|
+
"""Lint *project_path*, then drop every finding already present at
|
|
133
|
+
*baseline_ref*. Returns the same report shape as `build_report`, plus a
|
|
134
|
+
top-level `"baseline"` key, with the summary and total counting only
|
|
135
|
+
the new findings.
|
|
136
|
+
|
|
137
|
+
Raises `BaselineError` if the ref does not resolve or project_path is
|
|
138
|
+
not inside a git repo.
|
|
139
|
+
"""
|
|
140
|
+
sha = _resolve_ref(project_path, baseline_ref)
|
|
141
|
+
git_root = _git_root(project_path)
|
|
142
|
+
try:
|
|
143
|
+
rel_to_root = project_path.relative_to(git_root)
|
|
144
|
+
except ValueError:
|
|
145
|
+
rel_to_root = Path()
|
|
146
|
+
|
|
147
|
+
current_report, _ = build_report(
|
|
148
|
+
project_path,
|
|
149
|
+
skip=skip,
|
|
150
|
+
ty_python=ty_python,
|
|
151
|
+
ty_extra_search_paths=ty_extra_search_paths,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# The baseline worktree has no `.venv` of its own to discover: forward
|
|
155
|
+
# the current run's resolved ty environment, unless the caller already
|
|
156
|
+
# pinned one explicitly.
|
|
157
|
+
baseline_ty_python = ty_python
|
|
158
|
+
if baseline_ty_python is None and "ty" not in skip:
|
|
159
|
+
ty_environment = current_report.get("ty-environment", {})
|
|
160
|
+
baseline_ty_python = (
|
|
161
|
+
ty_environment.get("python_environment")
|
|
162
|
+
if isinstance(ty_environment, dict)
|
|
163
|
+
else None
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
tmpdir = tempfile.mkdtemp(prefix="python-linters-baseline-")
|
|
167
|
+
try:
|
|
168
|
+
worktree = subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # fixed argv list built by this package, never shell-interpolated or from untrusted input
|
|
169
|
+
[_GIT, "-C", str(project_path), "worktree", "add", "--detach", tmpdir, sha],
|
|
170
|
+
capture_output=True,
|
|
171
|
+
text=True,
|
|
172
|
+
check=False,
|
|
173
|
+
)
|
|
174
|
+
if worktree.returncode != 0:
|
|
175
|
+
msg = f"git worktree add failed: {worktree.stderr.strip()}"
|
|
176
|
+
raise BaselineError(msg)
|
|
177
|
+
|
|
178
|
+
baseline_path = Path(tmpdir) / rel_to_root
|
|
179
|
+
baseline_report, _ = build_report(
|
|
180
|
+
baseline_path,
|
|
181
|
+
skip=skip,
|
|
182
|
+
ty_python=baseline_ty_python,
|
|
183
|
+
ty_extra_search_paths=ty_extra_search_paths,
|
|
184
|
+
)
|
|
185
|
+
finally:
|
|
186
|
+
subprocess.run( # ruff: ignore[subprocess-without-shell-equals-true] # fixed argv list built by this package, never shell-interpolated or from untrusted input
|
|
187
|
+
[_GIT, "-C", str(project_path), "worktree", "remove", "--force", tmpdir],
|
|
188
|
+
capture_output=True,
|
|
189
|
+
text=True,
|
|
190
|
+
check=False,
|
|
191
|
+
)
|
|
192
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
193
|
+
|
|
194
|
+
baseline_triples = _baseline_triples(baseline_report)
|
|
195
|
+
new_report, total = _diff_report(current_report, baseline_triples)
|
|
196
|
+
new_report["baseline"] = {"ref": baseline_ref, "sha": sha}
|
|
197
|
+
if baseline_ty_python is not None:
|
|
198
|
+
new_report["baseline"]["ty-python"] = baseline_ty_python
|
|
199
|
+
return new_report, total
|
python_linters/cli_commands.py
CHANGED
|
@@ -5,30 +5,54 @@ import json
|
|
|
5
5
|
import sys
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
from python_linters.baseline_report import BaselineError, run_with_baseline
|
|
8
9
|
from python_linters.config_check import (
|
|
9
10
|
check_ruff_config,
|
|
10
11
|
check_ty_config,
|
|
11
12
|
format_ruff_report,
|
|
12
13
|
format_ty_report,
|
|
13
14
|
)
|
|
14
|
-
from python_linters.lint_report import build_report
|
|
15
|
+
from python_linters.lint_report import TOOL_NAMES, build_report
|
|
15
16
|
from python_linters.reference_files import reference_text
|
|
17
|
+
from python_linters.text_report import format_text_report
|
|
16
18
|
from python_linters.tool_errors import ToolFailedError
|
|
19
|
+
from python_linters.tool_versions import package_version, tool_versions
|
|
17
20
|
|
|
18
21
|
|
|
19
22
|
def _lint(args: argparse.Namespace) -> int:
|
|
23
|
+
if set(args.skip) >= set(TOOL_NAMES):
|
|
24
|
+
args.parser.error("--skip cannot skip every tool")
|
|
25
|
+
|
|
20
26
|
project_path = Path(args.path).resolve()
|
|
21
27
|
if not project_path.exists():
|
|
22
28
|
print(f"Error: {project_path} does not exist", file=sys.stderr)
|
|
23
29
|
return 2
|
|
24
30
|
|
|
25
31
|
try:
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
if args.baseline:
|
|
33
|
+
output, total_findings = run_with_baseline(
|
|
34
|
+
project_path,
|
|
35
|
+
args.baseline,
|
|
36
|
+
skip=frozenset(args.skip),
|
|
37
|
+
ty_python=args.ty_python,
|
|
38
|
+
ty_extra_search_paths=tuple(args.ty_extra_search_path),
|
|
39
|
+
)
|
|
40
|
+
else:
|
|
41
|
+
output, total_findings = build_report(
|
|
42
|
+
project_path,
|
|
43
|
+
skip=frozenset(args.skip),
|
|
44
|
+
ty_python=args.ty_python,
|
|
45
|
+
ty_extra_search_paths=tuple(args.ty_extra_search_path),
|
|
46
|
+
)
|
|
47
|
+
except (ToolFailedError, BaselineError) as e:
|
|
28
48
|
print(str(e), file=sys.stderr)
|
|
29
49
|
return 2
|
|
30
50
|
|
|
31
|
-
serialized =
|
|
51
|
+
serialized = (
|
|
52
|
+
format_text_report(output)
|
|
53
|
+
if args.format == "text"
|
|
54
|
+
else json.dumps(output, indent=2)
|
|
55
|
+
)
|
|
32
56
|
|
|
33
57
|
if args.out:
|
|
34
58
|
Path(args.out).write_text(serialized, encoding="utf-8")
|
|
@@ -39,6 +63,11 @@ def _lint(args: argparse.Namespace) -> int:
|
|
|
39
63
|
return 1 if total_findings else 0
|
|
40
64
|
|
|
41
65
|
|
|
66
|
+
def _version_text() -> str:
|
|
67
|
+
tools = ", ".join(f"{name} {v}" for name, v in tool_versions().items())
|
|
68
|
+
return f"python-linters {package_version()} ({tools})"
|
|
69
|
+
|
|
70
|
+
|
|
42
71
|
def _reference(args: argparse.Namespace) -> int:
|
|
43
72
|
print(reference_text(args.tool), end="")
|
|
44
73
|
return 0
|
|
@@ -72,6 +101,12 @@ def _check_config(args: argparse.Namespace) -> int:
|
|
|
72
101
|
|
|
73
102
|
def build_parser() -> argparse.ArgumentParser:
|
|
74
103
|
parser = argparse.ArgumentParser(prog="python-linters")
|
|
104
|
+
parser.add_argument(
|
|
105
|
+
"--version",
|
|
106
|
+
action="version",
|
|
107
|
+
version=_version_text(),
|
|
108
|
+
help="print this package's version and its bundled ruff/ty/pymodularity versions",
|
|
109
|
+
)
|
|
75
110
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
76
111
|
|
|
77
112
|
lint_parser = subparsers.add_parser(
|
|
@@ -79,7 +114,39 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
79
114
|
)
|
|
80
115
|
lint_parser.add_argument("path", nargs="?", default=".")
|
|
81
116
|
lint_parser.add_argument("--out", default=None)
|
|
82
|
-
lint_parser.
|
|
117
|
+
lint_parser.add_argument(
|
|
118
|
+
"--format",
|
|
119
|
+
choices=["json", "text"],
|
|
120
|
+
default="json",
|
|
121
|
+
help="json (default) or one finding per line, sorted by file then line",
|
|
122
|
+
)
|
|
123
|
+
lint_parser.add_argument(
|
|
124
|
+
"--skip",
|
|
125
|
+
action="append",
|
|
126
|
+
default=[],
|
|
127
|
+
choices=list(TOOL_NAMES),
|
|
128
|
+
help="skip this tool (repeatable); skipping all 4 is an error",
|
|
129
|
+
)
|
|
130
|
+
lint_parser.add_argument(
|
|
131
|
+
"--python",
|
|
132
|
+
dest="ty_python",
|
|
133
|
+
default=None,
|
|
134
|
+
help="forwarded verbatim as ty check's --python",
|
|
135
|
+
)
|
|
136
|
+
lint_parser.add_argument(
|
|
137
|
+
"--extra-search-path",
|
|
138
|
+
dest="ty_extra_search_path",
|
|
139
|
+
action="append",
|
|
140
|
+
default=[],
|
|
141
|
+
help="forwarded verbatim as ty check's --extra-search-path (repeatable)",
|
|
142
|
+
)
|
|
143
|
+
lint_parser.add_argument(
|
|
144
|
+
"--baseline",
|
|
145
|
+
default=None,
|
|
146
|
+
help="git ref: report only findings new since this ref "
|
|
147
|
+
"(identity: file, rule, message — line excluded)",
|
|
148
|
+
)
|
|
149
|
+
lint_parser.set_defaults(func=_lint, parser=lint_parser)
|
|
83
150
|
|
|
84
151
|
reference_parser = subparsers.add_parser(
|
|
85
152
|
"reference", help="print a shipped reference config to stdout"
|
python_linters/lint_report.py
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
"""Run ruff, ty and pymodularity and group their findings by rule.
|
|
1
|
+
"""Run ruff, ruff format --check, ty and pymodularity and group their findings by rule.
|
|
2
2
|
|
|
3
3
|
Ported from lint_grouped.py (quality-and-security-tools howtos), minus eslint.
|
|
4
4
|
|
|
5
5
|
Output structure:
|
|
6
6
|
{
|
|
7
7
|
"project": "/abs/path",
|
|
8
|
+
"tools": {"ruff": "0.17.5", "ty": "0.0.103", "pymodularity": "0.5.0"},
|
|
9
|
+
"skipped": ["pymodularity"],
|
|
10
|
+
"ty-environment": {
|
|
11
|
+
"project_root": "/abs/path",
|
|
12
|
+
"python_environment": "/abs/path/.venv",
|
|
13
|
+
"pythonpath": "/abs/legacy-copy"
|
|
14
|
+
},
|
|
8
15
|
"summary": {
|
|
9
16
|
"ruff": {"total": 100, "rules": 12},
|
|
17
|
+
"ruff-format": {"total": 2, "rules": 1},
|
|
10
18
|
"ty": {"total": 50, "rules": 8},
|
|
11
19
|
"pymodularity": {"total": 5, "rules": 2}
|
|
12
20
|
},
|
|
@@ -17,6 +25,12 @@ Output structure:
|
|
|
17
25
|
"locations": [{"file": "rel/path.py", "line": 12, "col": 5, "message": "..."}, ...]
|
|
18
26
|
}
|
|
19
27
|
},
|
|
28
|
+
"ruff-format": {
|
|
29
|
+
"unformatted": {
|
|
30
|
+
"count": 2,
|
|
31
|
+
"locations": [{"file": "rel/path.py", "line": 1, "col": 1, "message": "would be reformatted"}, ...]
|
|
32
|
+
}
|
|
33
|
+
},
|
|
20
34
|
"ty": {
|
|
21
35
|
"invalid-return-type": {
|
|
22
36
|
"severity": "major",
|
|
@@ -34,24 +48,52 @@ Output structure:
|
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
Rules are sorted by count (descending) so the most common issues come first.
|
|
51
|
+
|
|
52
|
+
`ty-environment` is present only when ty itself ran (absent if "ty" is in
|
|
53
|
+
`skipped`); its `pythonpath` key is present only when `PYTHONPATH` was set.
|
|
54
|
+
A skipped tool gets no `summary` entry and no section of its own.
|
|
37
55
|
"""
|
|
38
56
|
|
|
39
57
|
from __future__ import annotations
|
|
40
58
|
|
|
41
59
|
import json
|
|
42
60
|
import os
|
|
61
|
+
import re
|
|
43
62
|
import sys
|
|
44
63
|
from dataclasses import dataclass, field
|
|
45
64
|
from pathlib import Path
|
|
46
65
|
from typing import TYPE_CHECKING
|
|
47
66
|
|
|
48
|
-
from python_linters.tool_errors import run_checked
|
|
67
|
+
from python_linters.tool_errors import ToolFailedError, run_checked
|
|
68
|
+
from python_linters.tool_versions import tool_versions
|
|
49
69
|
|
|
50
70
|
if TYPE_CHECKING:
|
|
51
71
|
from collections.abc import Iterable
|
|
52
72
|
|
|
53
73
|
_TY_SEVERITY_MAP = {"major": "error", "minor": "warning", "info": "info"}
|
|
54
74
|
|
|
75
|
+
# Matches ruff format --check's per-file location line, e.g.:
|
|
76
|
+
# --> my_package/main_module.py:1:2
|
|
77
|
+
_RUFF_FORMAT_LOCATION_RE = re.compile(r"^ --> (?P<file>.+):(?P<line>\d+):(?P<col>\d+)$")
|
|
78
|
+
|
|
79
|
+
# `ty check -vv`'s DEBUG lines that name the project root it resolved.
|
|
80
|
+
# "Found project at" fires when the root differs from (or has its own
|
|
81
|
+
# `tool.ty` config at) the searched path; "Project without `tool.ty`
|
|
82
|
+
# section" fires when ty found no config and settled on the searched path
|
|
83
|
+
# itself. Either line, found first, names the actual root.
|
|
84
|
+
_TY_FOUND_PROJECT_RE = re.compile(r"Found project at '(?P<root>[^']+)'")
|
|
85
|
+
_TY_PROJECT_WITHOUT_CONFIG_RE = re.compile(
|
|
86
|
+
r"Project without `tool\.ty` section: '(?P<root>[^']+)'"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# `ty check -vv`'s DEBUG line that names the sys.prefix of the Python
|
|
90
|
+
# environment it actually resolved for import resolution — the same line
|
|
91
|
+
# fires whether that environment came from VIRTUAL_ENV, a discovered
|
|
92
|
+
# `.venv`, or an explicit `--python`.
|
|
93
|
+
_TY_SYS_PREFIX_RE = re.compile(
|
|
94
|
+
r"Searching for site-packages directory in sys\.prefix (?P<prefix>\S+)"
|
|
95
|
+
)
|
|
96
|
+
|
|
55
97
|
|
|
56
98
|
@dataclass
|
|
57
99
|
class Location:
|
|
@@ -120,7 +162,7 @@ def run_ruff(project_path: Path) -> dict[str, FindingGroup]:
|
|
|
120
162
|
# Resolution: the python-linters environment's own pinned ruff, run as
|
|
121
163
|
# `sys.executable -m ruff` so it never falls through to PATH. In
|
|
122
164
|
# dev-dependency mode that environment is the linted project's venv, so
|
|
123
|
-
# ruff==0.17.
|
|
165
|
+
# ruff==0.17.5 (pinned in python-linters' own pyproject.toml) is what
|
|
124
166
|
# actually runs — never a PATH ruff of another version, which lints
|
|
125
167
|
# with other rules and may refuse the reference config.
|
|
126
168
|
#
|
|
@@ -161,7 +203,96 @@ def run_ruff(project_path: Path) -> dict[str, FindingGroup]:
|
|
|
161
203
|
return _group_findings(findings())
|
|
162
204
|
|
|
163
205
|
|
|
164
|
-
def
|
|
206
|
+
def run_ruff_format(project_path: Path) -> dict[str, FindingGroup]:
|
|
207
|
+
# Resolution: same as run_ruff above — `sys.executable -m ruff`, never
|
|
208
|
+
# PATH, `--no-cache` so a read-only linted project tree is never an
|
|
209
|
+
# issue.
|
|
210
|
+
#
|
|
211
|
+
# `ruff format --check` has no JSON output mode (unlike `ruff check`):
|
|
212
|
+
# `ruff format --help` lists `--output-format` only under `check`, not
|
|
213
|
+
# under `format`. Its human-readable stdout does carry one `-->
|
|
214
|
+
# path:line:col` line per unformatted file, which is parsed below
|
|
215
|
+
# instead.
|
|
216
|
+
#
|
|
217
|
+
# ruff format's exit codes: 0 all formatted, 1 some file would be
|
|
218
|
+
# reformatted, 2 tool failure (bad config, crash, ...). A failure must
|
|
219
|
+
# never read as "0 findings" — `run_checked` raises instead of
|
|
220
|
+
# returning in that case.
|
|
221
|
+
result = run_checked(
|
|
222
|
+
[sys.executable, "-m", "ruff", "format", "--check", "--no-cache", "."],
|
|
223
|
+
project_path,
|
|
224
|
+
"ruff-format",
|
|
225
|
+
ok_codes=(0, 1),
|
|
226
|
+
)
|
|
227
|
+
if not result.stdout.strip():
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
def findings() -> Iterable[_Finding]:
|
|
231
|
+
for line in result.stdout.splitlines():
|
|
232
|
+
match = _RUFF_FORMAT_LOCATION_RE.match(line)
|
|
233
|
+
if match is None:
|
|
234
|
+
continue
|
|
235
|
+
location = Location(
|
|
236
|
+
file=_relative_to(match["file"], project_path),
|
|
237
|
+
line=int(match["line"]),
|
|
238
|
+
col=int(match["col"]),
|
|
239
|
+
message="would be reformatted",
|
|
240
|
+
)
|
|
241
|
+
yield "unformatted", None, None, location
|
|
242
|
+
|
|
243
|
+
groups = _group_findings(findings())
|
|
244
|
+
# Exit 1 with nothing parsed means ruff changed its text output: fail
|
|
245
|
+
# loudly instead of reporting a clean project.
|
|
246
|
+
if result.returncode == 1 and not groups:
|
|
247
|
+
detail = f"exit 1 but no ' --> path:line:col' line parsed:\n{result.stdout}"
|
|
248
|
+
tool = "ruff-format"
|
|
249
|
+
raise ToolFailedError(tool, detail)
|
|
250
|
+
return groups
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_ty_environment(stderr: str) -> dict[str, str]:
|
|
254
|
+
"""Pull ty's resolved project root and Python environment out of its
|
|
255
|
+
`-vv` DEBUG log lines.
|
|
256
|
+
|
|
257
|
+
ty prints no structured ("--output-format json"-style) way to ask for
|
|
258
|
+
this, so the log lines are the only source. Both a project root line
|
|
259
|
+
and a sys.prefix line are expected on every run (see the regex
|
|
260
|
+
docstrings above) — their absence means ty changed its logging, so
|
|
261
|
+
this fails loudly instead of reporting a guessed or stale environment.
|
|
262
|
+
"""
|
|
263
|
+
environment: dict[str, str] = {}
|
|
264
|
+
|
|
265
|
+
root_match = _TY_FOUND_PROJECT_RE.search(
|
|
266
|
+
stderr
|
|
267
|
+
) or _TY_PROJECT_WITHOUT_CONFIG_RE.search(stderr)
|
|
268
|
+
if root_match is None:
|
|
269
|
+
tool = "ty"
|
|
270
|
+
detail = f"-vv output named no project root; ty may have changed its logging:\n{stderr}"
|
|
271
|
+
raise ToolFailedError(tool, detail)
|
|
272
|
+
environment["project_root"] = root_match["root"]
|
|
273
|
+
|
|
274
|
+
prefix_match = _TY_SYS_PREFIX_RE.search(stderr)
|
|
275
|
+
if prefix_match is None:
|
|
276
|
+
tool = "ty"
|
|
277
|
+
detail = (
|
|
278
|
+
"-vv output named no resolved Python environment; ty may have "
|
|
279
|
+
f"changed its logging:\n{stderr}"
|
|
280
|
+
)
|
|
281
|
+
raise ToolFailedError(tool, detail)
|
|
282
|
+
environment["python_environment"] = prefix_match["prefix"]
|
|
283
|
+
|
|
284
|
+
pythonpath = os.environ.get("PYTHONPATH", "")
|
|
285
|
+
if pythonpath != "":
|
|
286
|
+
environment["pythonpath"] = pythonpath
|
|
287
|
+
|
|
288
|
+
return environment
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def run_ty(
|
|
292
|
+
project_path: Path,
|
|
293
|
+
python: str | None = None,
|
|
294
|
+
extra_search_paths: tuple[str, ...] = (),
|
|
295
|
+
) -> tuple[dict[str, FindingGroup], dict[str, str]]:
|
|
165
296
|
# Resolution: `sys.executable -m ty` from the python-linters
|
|
166
297
|
# environment, never a bare `ty` entry point (that one falls through to
|
|
167
298
|
# PATH when the environment has no ty on it) and never a PATH `ty`.
|
|
@@ -188,16 +319,35 @@ def run_ty(project_path: Path) -> dict[str, FindingGroup]:
|
|
|
188
319
|
# VIRTUAL_ENV here makes ty fall through to discovering `.venv` under
|
|
189
320
|
# project_path itself. In dev-dependency mode this is a no-op: there
|
|
190
321
|
# the project's own venv was VIRTUAL_ENV already.
|
|
322
|
+
#
|
|
323
|
+
# `--python`/`--extra-search-path` let a caller override this
|
|
324
|
+
# discovery outright — needed e.g. for a ROS 2 package that resolves
|
|
325
|
+
# its imports from a system interpreter and `/opt/ros/...`, never a
|
|
326
|
+
# `.venv` under the linted path.
|
|
327
|
+
#
|
|
328
|
+
# `-vv`: only source for ty's resolved project root and Python
|
|
329
|
+
# environment (see `_parse_ty_environment`). Its DEBUG lines go to
|
|
330
|
+
# stderr, never stdout, so the `--output-format gitlab` JSON below is
|
|
331
|
+
# unaffected.
|
|
191
332
|
ty_env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
|
|
333
|
+
cmd = [sys.executable, "-m", "ty", "check", "--output-format", "gitlab", "-vv"]
|
|
334
|
+
if python is not None:
|
|
335
|
+
cmd += ["--python", python]
|
|
336
|
+
for search_path in extra_search_paths:
|
|
337
|
+
cmd += ["--extra-search-path", search_path]
|
|
338
|
+
cmd.append(".")
|
|
339
|
+
|
|
192
340
|
result = run_checked(
|
|
193
|
-
|
|
341
|
+
cmd,
|
|
194
342
|
project_path,
|
|
195
343
|
"ty",
|
|
196
344
|
ok_codes=(0, 1),
|
|
197
345
|
env=ty_env,
|
|
198
346
|
)
|
|
347
|
+
ty_environment = _parse_ty_environment(result.stderr)
|
|
348
|
+
|
|
199
349
|
if not result.stdout.strip():
|
|
200
|
-
return {}
|
|
350
|
+
return {}, ty_environment
|
|
201
351
|
|
|
202
352
|
diagnostics = json.loads(result.stdout)
|
|
203
353
|
|
|
@@ -228,7 +378,7 @@ def run_ty(project_path: Path) -> dict[str, FindingGroup]:
|
|
|
228
378
|
)
|
|
229
379
|
yield rule, None, severity, location
|
|
230
380
|
|
|
231
|
-
return _group_findings(findings())
|
|
381
|
+
return _group_findings(findings()), ty_environment
|
|
232
382
|
|
|
233
383
|
|
|
234
384
|
def run_pymodularity(project_path: Path) -> dict[str, FindingGroup]:
|
|
@@ -274,19 +424,38 @@ def run_pymodularity(project_path: Path) -> dict[str, FindingGroup]:
|
|
|
274
424
|
return _group_findings(findings())
|
|
275
425
|
|
|
276
426
|
|
|
277
|
-
|
|
427
|
+
# The 4 tools `lint` can run, in report order. Shared with the CLI's
|
|
428
|
+
# `--skip` choices so the two never drift apart.
|
|
429
|
+
TOOL_NAMES = ("ruff", "ruff-format", "ty", "pymodularity")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def build_report(
|
|
433
|
+
project_path: Path,
|
|
434
|
+
skip: frozenset[str] = frozenset(),
|
|
435
|
+
ty_python: str | None = None,
|
|
436
|
+
ty_extra_search_paths: tuple[str, ...] = (),
|
|
437
|
+
) -> tuple[dict[str, object], int]:
|
|
278
438
|
"""Return the JSON-ready report and the total finding count across all tools."""
|
|
279
439
|
summary: dict[str, object] = {}
|
|
280
440
|
sections: dict[str, object] = {}
|
|
281
441
|
grand_total = 0
|
|
442
|
+
ty_environment: dict[str, str] = {}
|
|
443
|
+
|
|
444
|
+
for tool in TOOL_NAMES:
|
|
445
|
+
if tool in skip:
|
|
446
|
+
continue
|
|
282
447
|
|
|
283
|
-
for tool, run in (
|
|
284
|
-
("ruff", run_ruff),
|
|
285
|
-
("ty", run_ty),
|
|
286
|
-
("pymodularity", run_pymodularity),
|
|
287
|
-
):
|
|
288
448
|
print(f"Running {tool} on {project_path} ...", file=sys.stderr)
|
|
289
|
-
|
|
449
|
+
if tool == "ruff":
|
|
450
|
+
violations = run_ruff(project_path)
|
|
451
|
+
elif tool == "ruff-format":
|
|
452
|
+
violations = run_ruff_format(project_path)
|
|
453
|
+
elif tool == "ty":
|
|
454
|
+
violations, ty_environment = run_ty(
|
|
455
|
+
project_path, python=ty_python, extra_search_paths=ty_extra_search_paths
|
|
456
|
+
)
|
|
457
|
+
else:
|
|
458
|
+
violations = run_pymodularity(project_path)
|
|
290
459
|
total = sum(group.count for group in violations.values())
|
|
291
460
|
print(f" {total} violations across {len(violations)} rules", file=sys.stderr)
|
|
292
461
|
|
|
@@ -294,5 +463,13 @@ def build_report(project_path: Path) -> tuple[dict[str, object], int]:
|
|
|
294
463
|
sections[tool] = {rule: group.to_dict() for rule, group in violations.items()}
|
|
295
464
|
grand_total += total
|
|
296
465
|
|
|
297
|
-
report
|
|
466
|
+
report: dict[str, object] = {
|
|
467
|
+
"project": str(project_path),
|
|
468
|
+
"tools": tool_versions(),
|
|
469
|
+
"skipped": sorted(skip),
|
|
470
|
+
}
|
|
471
|
+
if ty_environment:
|
|
472
|
+
report["ty-environment"] = ty_environment
|
|
473
|
+
report["summary"] = summary
|
|
474
|
+
report.update(sections)
|
|
298
475
|
return report, grand_total
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# 5. flake8-type-checking — list your runtime-evaluated base classes and
|
|
13
13
|
# decorators (pydantic, FastAPI preset)
|
|
14
14
|
#
|
|
15
|
-
# Last triaged against: ruff 0.17.
|
|
15
|
+
# Last triaged against: ruff 0.17.5
|
|
16
16
|
# Rule names in `select`/`ignore` and in `# ruff: ignore[...]` comments need
|
|
17
17
|
# `preview = true`: without it ruff rejects the config and treats name-based
|
|
18
18
|
# suppression comments as inert.
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
# Older ruff fails on the first rule name it does not know, with a cryptic
|
|
22
22
|
# `Unknown rule selector`. This guard names the version instead.
|
|
23
|
-
required-version = ">=0.17.
|
|
23
|
+
required-version = ">=0.17.5"
|
|
24
24
|
|
|
25
25
|
target-version = "py312"
|
|
26
26
|
|
|
@@ -94,6 +94,8 @@ select = [
|
|
|
94
94
|
|
|
95
95
|
# ── Security ──
|
|
96
96
|
"S", # flake8-bandit — security antipatterns (hardcoded passwords, exec, …)
|
|
97
|
+
"tarfile-ignore-zeros-true", # code-less rule: `ignore_zeros=True` reads past the
|
|
98
|
+
# end-of-archive marker, so appended members get extracted too
|
|
97
99
|
"EXE", # flake8-executable — shebang correctness
|
|
98
100
|
|
|
99
101
|
# ── Error handling ──
|
|
@@ -170,11 +172,11 @@ select = [
|
|
|
170
172
|
|
|
171
173
|
# ── Preview rules, selected by name ──────────────────────────────────
|
|
172
174
|
# `explicit-preview-rules = true`: a category select above turns on only its
|
|
173
|
-
# stable rules. Each preview rule below was triaged on ruff 0.17.
|
|
175
|
+
# stable rules. Each preview rule below was triaged on ruff 0.17.5.
|
|
174
176
|
# A preview rule in a selected category that is neither listed
|
|
175
177
|
# here nor covered by an `# excluded` line below is untriaged.
|
|
176
178
|
|
|
177
|
-
# Ruff-specific (
|
|
179
|
+
# Ruff-specific (26)
|
|
178
180
|
# bugs: the code runs but does the wrong thing
|
|
179
181
|
"missing-f-string-syntax", # `"{x}"` without `f` prints the braces
|
|
180
182
|
"unraw-re-pattern", # `"\b"` in a non-raw pattern is a backspace, not a word boundary, and nothing else warns
|
|
@@ -204,13 +206,15 @@ select = [
|
|
|
204
206
|
"noqa-comments",
|
|
205
207
|
"rule-codes-in-suppression-comments",
|
|
206
208
|
"rule-codes-in-selectors",
|
|
209
|
+
"renamed-rule", # a config selector that only works through a rename redirect
|
|
207
210
|
|
|
208
|
-
# Pylint (
|
|
211
|
+
# Pylint (19)
|
|
209
212
|
# bugs: runtime errors or silently lost behavior
|
|
210
213
|
"dict-iter-missing-items", # `for k, v in d` unpacks keys, not items
|
|
211
214
|
"modified-iterating-set", # RuntimeError
|
|
212
215
|
"redefined-slots-in-subclass", # makes the base class slot unreachable
|
|
213
216
|
"bad-dunder-method-name", # a misspelled dunder is never called
|
|
217
|
+
"no-method-argument", # a method without `self`/`cls` raises TypeError when called on an instance
|
|
214
218
|
"unspecified-encoding", # `open()` without encoding uses the platform locale
|
|
215
219
|
# size and nesting limits, every threshold set under [lint.pylint]
|
|
216
220
|
"too-many-public-methods",
|
|
@@ -262,9 +266,11 @@ select = [
|
|
|
262
266
|
"deprecated-abc-decorator", # deprecated since 3.3
|
|
263
267
|
"context-manager-iterator", # @contextmanager needs Generator, Iterator lacks `throw`
|
|
264
268
|
|
|
265
|
-
# flake8-async (
|
|
266
|
-
|
|
267
|
-
|
|
269
|
+
# flake8-async (2)
|
|
270
|
+
"yield-in-context-manager-in-async-generator", # the context manager's cleanup waits until
|
|
271
|
+
# the generator closes, when `await` is no longer allowed
|
|
272
|
+
"pytest-raises-exception-group", # `pytest.RaisesGroup` checks the grouped exceptions,
|
|
273
|
+
# `pytest.raises(ExceptionGroup)` only the wrapper
|
|
268
274
|
|
|
269
275
|
# flake8-pytest-style (1): `pytest.warns()` without a warning fails at runtime
|
|
270
276
|
"pytest-warns-without-warning",
|
|
@@ -391,7 +397,7 @@ ignore = [
|
|
|
391
397
|
# callers, so its method count is not an
|
|
392
398
|
# interface
|
|
393
399
|
# missing return type — test functions always return None
|
|
394
|
-
"missing-return-type-
|
|
400
|
+
"missing-return-type-public-function",
|
|
395
401
|
"import-private-name", # testing internal functions is legitimate
|
|
396
402
|
"unused-async", # async test doubles mirror the awaited signature they replace
|
|
397
403
|
"float-equality-comparison", # asserting an exactly representable result is correct
|
|
@@ -408,7 +414,7 @@ ignore = [
|
|
|
408
414
|
"magic-value-comparison",
|
|
409
415
|
"no-self-use",
|
|
410
416
|
"too-many-public-methods",
|
|
411
|
-
"missing-return-type-
|
|
417
|
+
"missing-return-type-public-function",
|
|
412
418
|
"implicit-namespace-package", # standalone test scripts
|
|
413
419
|
"import-private-name",
|
|
414
420
|
"unused-async",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# ty Reference Configuration
|
|
3
3
|
# ============================================================================
|
|
4
4
|
#
|
|
5
|
-
# Canonical rule table for new Python projects. Every rule ty 0.0.
|
|
5
|
+
# Canonical rule table for new Python projects. Every rule ty 0.0.103 knows is
|
|
6
6
|
# listed below, one line each, with no `all = "error"` base. Each line is
|
|
7
7
|
# "error" or "ignore". A "warn" setting is never used, because a warning
|
|
8
8
|
# does not fail a CI gate, so a green run hides it.
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
# - into `pyproject.toml` under `[tool.ty.rules]` (rename the table header,
|
|
17
17
|
# keep every line).
|
|
18
18
|
#
|
|
19
|
-
# Last triaged against: ty 0.0.
|
|
20
|
-
#
|
|
19
|
+
# Last triaged against: ty 0.0.103 (152 rules: 107 error-by-default,
|
|
20
|
+
# 27 warn-by-default, 18 ignore-by-default).
|
|
21
21
|
#
|
|
22
22
|
# Re-check the copied table after every ty version bump:
|
|
23
23
|
# python-linters check-config ty <path/to/ty.toml>
|
|
@@ -51,7 +51,7 @@ redundant-condition-strict = "ignore"
|
|
|
51
51
|
# `re.Match.group()`) goes unflagged.
|
|
52
52
|
# Re-triage on each ty bump: ty is 0.0.x and its inference changes.
|
|
53
53
|
unsound-assignment = "ignore"
|
|
54
|
-
unsound-return
|
|
54
|
+
unsound-return = "ignore"
|
|
55
55
|
unsound-yield = "ignore"
|
|
56
56
|
|
|
57
57
|
# not applicable: checks classes built with `type()`, which the doctrine
|
|
@@ -80,66 +80,69 @@ truthiness-test-of-none-union = "error"
|
|
|
80
80
|
# --- Enforced rules: on by default in ty, alphabetical ----------------------
|
|
81
81
|
|
|
82
82
|
abstract-and-final-method = "error"
|
|
83
|
+
abstract-class-instantiation = "error"
|
|
84
|
+
abstract-method-call = "error"
|
|
83
85
|
abstract-method-in-final-class = "error"
|
|
84
86
|
ambiguous-protocol-member = "error"
|
|
85
87
|
assert-type-unspellable-subtype = "error"
|
|
86
|
-
call-
|
|
87
|
-
call-non-callable = "error"
|
|
88
|
-
call-top-callable = "error"
|
|
88
|
+
call-to-function-with-unknown-signature = "error"
|
|
89
89
|
conflicting-declarations = "error"
|
|
90
90
|
conflicting-metaclass = "error"
|
|
91
91
|
cyclic-class-definition = "error"
|
|
92
92
|
cyclic-type-alias-definition = "error"
|
|
93
|
-
dataclass-field-order = "error"
|
|
94
93
|
deprecated = "error"
|
|
95
94
|
duplicate-base = "error"
|
|
96
95
|
duplicate-kw-only = "error"
|
|
97
|
-
empty-body = "error"
|
|
98
96
|
escape-character-in-forward-annotation = "error"
|
|
99
97
|
experimental-syntax = "error"
|
|
100
98
|
final-on-non-method = "error"
|
|
101
99
|
final-without-value = "error"
|
|
102
100
|
ignore-comment-unknown-rule = "error"
|
|
103
|
-
implicit-
|
|
101
|
+
implicit-string-concatenation-in-annotation = "error"
|
|
102
|
+
import-not-found = "error"
|
|
103
|
+
incompatible-attribute-override = "error"
|
|
104
|
+
incompatible-method-override = "error"
|
|
105
|
+
incompatible-mutable-override = "error"
|
|
106
|
+
incompatible-property-override = "error"
|
|
104
107
|
inconsistent-mro = "error"
|
|
108
|
+
inconsistent-overload-decorator = "error"
|
|
109
|
+
inconsistent-overload-implementation = "error"
|
|
105
110
|
index-out-of-bounds = "error"
|
|
106
111
|
ineffective-final = "error"
|
|
107
112
|
instance-layout-conflict = "error"
|
|
108
113
|
invalid-argument-type = "error"
|
|
109
114
|
invalid-assignment = "error"
|
|
110
115
|
invalid-attribute-access = "error"
|
|
111
|
-
invalid-attribute-override = "error"
|
|
112
116
|
invalid-await = "error"
|
|
113
117
|
invalid-base = "error"
|
|
114
118
|
invalid-context-manager = "error"
|
|
115
119
|
invalid-dataclass = "error"
|
|
120
|
+
invalid-dataclass-field-order = "error"
|
|
116
121
|
invalid-dataclass-override = "error"
|
|
117
122
|
invalid-declaration = "error"
|
|
118
123
|
invalid-enum-member-annotation = "error"
|
|
119
124
|
invalid-exception-caught = "error"
|
|
120
125
|
invalid-explicit-override = "error"
|
|
126
|
+
invalid-explicit-return = "error"
|
|
121
127
|
invalid-frozen-dataclass-subclass = "error"
|
|
128
|
+
invalid-generator-return-type = "error"
|
|
122
129
|
invalid-generic-class = "error"
|
|
123
130
|
invalid-generic-enum = "error"
|
|
124
131
|
invalid-ignore-comment = "error"
|
|
132
|
+
invalid-implicit-return = "error"
|
|
133
|
+
invalid-implicit-return-in-function-with-stub-body = "error"
|
|
125
134
|
invalid-init-type-variable = "error"
|
|
126
|
-
invalid-key = "error"
|
|
127
135
|
invalid-legacy-type-variable = "error"
|
|
128
136
|
invalid-match-pattern = "error"
|
|
129
137
|
invalid-metaclass = "error"
|
|
130
|
-
invalid-method-override = "error"
|
|
131
138
|
invalid-module-getattr-call = "error"
|
|
132
|
-
invalid-mutable-override = "error"
|
|
133
139
|
invalid-named-tuple = "error"
|
|
134
140
|
invalid-named-tuple-override = "error"
|
|
135
141
|
invalid-newtype = "error"
|
|
136
|
-
invalid-overload = "error"
|
|
137
142
|
invalid-parameter-default = "error"
|
|
138
143
|
invalid-paramspec = "error"
|
|
139
|
-
invalid-property-type-override = "error"
|
|
140
144
|
invalid-protocol = "error"
|
|
141
145
|
invalid-raise = "error"
|
|
142
|
-
invalid-return-type = "error"
|
|
143
146
|
invalid-super-argument = "error"
|
|
144
147
|
invalid-syntax-in-forward-annotation = "error"
|
|
145
148
|
invalid-total-ordering = "error"
|
|
@@ -153,16 +156,24 @@ invalid-type-variable-constraints = "error"
|
|
|
153
156
|
invalid-type-variable-default = "error"
|
|
154
157
|
invalid-typed-dict-field = "error"
|
|
155
158
|
invalid-typed-dict-header = "error"
|
|
159
|
+
invalid-typed-dict-key = "error"
|
|
156
160
|
invalid-typed-dict-statement = "error"
|
|
157
161
|
invalid-yield = "error"
|
|
158
162
|
isinstance-against-protocol = "error"
|
|
159
163
|
isinstance-against-typed-dict = "error"
|
|
164
|
+
lost-keyword-for-regular-parameter = "error"
|
|
160
165
|
mismatched-type-name = "error"
|
|
166
|
+
misplaced-overload-decorator = "error"
|
|
161
167
|
missing-argument = "error"
|
|
168
|
+
missing-attribute = "error"
|
|
169
|
+
missing-overload-implementation = "error"
|
|
162
170
|
missing-slot = "error"
|
|
163
171
|
missing-typed-dict-key = "error"
|
|
172
|
+
missing-union-attribute = "error"
|
|
164
173
|
no-matching-overload = "error"
|
|
165
174
|
non-callable-init-subclass = "error"
|
|
175
|
+
non-literal-typed-dict-key = "error"
|
|
176
|
+
not-callable = "error"
|
|
166
177
|
not-iterable = "error"
|
|
167
178
|
not-subscriptable = "error"
|
|
168
179
|
override-of-final-method = "error"
|
|
@@ -176,7 +187,9 @@ raw-string-type-annotation = "error"
|
|
|
176
187
|
redundant-cast = "error"
|
|
177
188
|
redundant-condition = "error"
|
|
178
189
|
redundant-final-classvar = "error"
|
|
190
|
+
renamed-rule-in-ignore-comment = "error"
|
|
179
191
|
shadowed-type-variable = "error"
|
|
192
|
+
single-overload = "error"
|
|
180
193
|
static-assert-error = "error"
|
|
181
194
|
subclass-of-dataclass-with-order = "error"
|
|
182
195
|
subclass-of-final-class = "error"
|
|
@@ -189,9 +202,8 @@ unavailable-implicit-super-arguments = "error"
|
|
|
189
202
|
unbound-type-variable = "error"
|
|
190
203
|
undefined-reveal = "error"
|
|
191
204
|
unknown-argument = "error"
|
|
192
|
-
|
|
205
|
+
unknown-typed-dict-constructor-key = "error"
|
|
193
206
|
unresolved-global = "error"
|
|
194
|
-
unresolved-import = "error"
|
|
195
207
|
unresolved-reference = "error"
|
|
196
208
|
unsupported-base = "error"
|
|
197
209
|
unsupported-bool-conversion = "error"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Render a `build_report` result as one line per finding, for a terminal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from python_linters.lint_report import TOOL_NAMES
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_text_report(report: dict[str, object]) -> str:
|
|
9
|
+
"""One line per finding, sorted by file then line, with a totals line last.
|
|
10
|
+
|
|
11
|
+
`file:line:col tool/rule message`, matching how ruff and ty print their
|
|
12
|
+
own findings — unlike the JSON report, this is for a human reading a
|
|
13
|
+
terminal, not a script.
|
|
14
|
+
"""
|
|
15
|
+
rows: list[tuple[str, int, int, str, str, str]] = []
|
|
16
|
+
for tool in TOOL_NAMES:
|
|
17
|
+
section = report.get(tool)
|
|
18
|
+
if not isinstance(section, dict):
|
|
19
|
+
continue
|
|
20
|
+
for rule, group in section.items():
|
|
21
|
+
rows.extend(
|
|
22
|
+
(
|
|
23
|
+
location["file"],
|
|
24
|
+
location["line"],
|
|
25
|
+
location["col"],
|
|
26
|
+
tool,
|
|
27
|
+
rule,
|
|
28
|
+
location["message"],
|
|
29
|
+
)
|
|
30
|
+
for location in group["locations"]
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
rows.sort(key=lambda row: (row[0], row[1]))
|
|
34
|
+
|
|
35
|
+
lines = [
|
|
36
|
+
f"{file}:{line}:{col} {tool}/{rule} {message}"
|
|
37
|
+
for file, line, col, tool, rule, message in rows
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
summary_obj = report.get("summary", {})
|
|
41
|
+
summary: dict[str, dict[str, int]] = (
|
|
42
|
+
summary_obj if isinstance(summary_obj, dict) else {}
|
|
43
|
+
)
|
|
44
|
+
totals = " ".join(f"{tool}={data['total']}" for tool, data in summary.items())
|
|
45
|
+
grand_total = sum(data["total"] for data in summary.values())
|
|
46
|
+
totals_line = f"{totals} total={grand_total}" if totals else f"total={grand_total}"
|
|
47
|
+
|
|
48
|
+
baseline = report.get("baseline")
|
|
49
|
+
if isinstance(baseline, dict):
|
|
50
|
+
totals_line += f" baseline={baseline['ref']}"
|
|
51
|
+
|
|
52
|
+
lines.append(totals_line)
|
|
53
|
+
|
|
54
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Resolved versions of this package and the tools it bundles.
|
|
2
|
+
|
|
3
|
+
Read from `importlib.metadata`, never from a subprocess call: the versions
|
|
4
|
+
are pinned in `pyproject.toml` and installed into the same environment this
|
|
5
|
+
package itself runs in, so the installed-distribution metadata already
|
|
6
|
+
names exactly what `sys.executable -m <tool>` will run.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import version
|
|
12
|
+
|
|
13
|
+
TOOL_DISTRIBUTIONS = ("ruff", "ty", "pymodularity")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def package_version() -> str:
|
|
17
|
+
return version("python-linters")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def tool_versions() -> dict[str, str]:
|
|
21
|
+
"""Bundled ruff/ty/pymodularity versions, keyed by distribution name."""
|
|
22
|
+
return {name: version(name) for name in TOOL_DISTRIBUTIONS}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-linters
|
|
3
|
+
Version: 0.2.3
|
|
4
|
+
Summary: Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule.
|
|
5
|
+
Author: Tilo Himmelsbach
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Dist: ruff==0.17.5
|
|
8
|
+
Requires-Dist: ty==0.0.103
|
|
9
|
+
Requires-Dist: pymodularity==0.5.0
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# python-linters
|
|
14
|
+
|
|
15
|
+
Run ruff, ty and pymodularity on a Python project and print one JSON report
|
|
16
|
+
grouped by rule, for agents and CI to read.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
As a dev dependency of the project you want to lint:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
uv add --dev python-linters
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
- `uv run python-linters lint [path] [--out FILE] [--format json|text]
|
|
29
|
+
[--skip TOOL]... [--python PATH] [--extra-search-path DIR]...
|
|
30
|
+
[--baseline REF]` — runs
|
|
31
|
+
ruff, `ruff format --check`, ty and pymodularity (default path: `.`) and
|
|
32
|
+
prints the merged report to stdout, or writes it to `FILE` with `--out`.
|
|
33
|
+
- `--format text` prints one line per finding (`file:line:col
|
|
34
|
+
tool/rule message`, sorted by file then line) with a totals line
|
|
35
|
+
last, instead of the default JSON. `--out` writes whichever format
|
|
36
|
+
was chosen.
|
|
37
|
+
- `--skip TOOL` (repeatable; `ruff`, `ruff-format`, `ty`,
|
|
38
|
+
`pymodularity`) drops that tool: it does not run, gets no section and
|
|
39
|
+
no summary entry, and is listed under the report's top-level
|
|
40
|
+
`"skipped"`. Skipping all 4 is a usage error.
|
|
41
|
+
- `--python PATH` and `--extra-search-path DIR` (repeatable) are
|
|
42
|
+
forwarded verbatim to `ty check`'s own `--python`/`--extra-search-path`.
|
|
43
|
+
Needed whenever the project's imports do not resolve from a `.venv`
|
|
44
|
+
under the linted path — e.g. a ROS 2 package that resolves `rclpy`
|
|
45
|
+
and friends from a system interpreter and `/opt/ros/...`.
|
|
46
|
+
- ty looks for a `.venv` at *its own project root* — the directory
|
|
47
|
+
holding the nearest `ty.toml`, or a `pyproject.toml` with a
|
|
48
|
+
`[tool.ty]` section — not at the path you pass to `lint`. A shared
|
|
49
|
+
parent config can point ty's `.venv` search at the wrong directory for
|
|
50
|
+
a sub-package; the report's `"ty-environment"` key (below) shows what
|
|
51
|
+
ty actually resolved.
|
|
52
|
+
- ty ranks `$PYTHONPATH` entries ahead of first-party code. If
|
|
53
|
+
`PYTHONPATH` is set, a stale installed copy on it can shadow the
|
|
54
|
+
project tree being linted and silently report 0 findings; the report
|
|
55
|
+
names it under `"ty-environment"` when set.
|
|
56
|
+
- the report's top-level `"tools"` key names the bundled ruff/ty/
|
|
57
|
+
pymodularity versions; `"ty-environment"` names ty's resolved project
|
|
58
|
+
root and Python environment (absent if `ty` is skipped).
|
|
59
|
+
- `--baseline REF` (a branch, tag, SHA, `HEAD~N`, ...) reports only
|
|
60
|
+
findings that are new since that git ref: it checks the ref out into a
|
|
61
|
+
fresh `git worktree add --detach` scratch directory (never a reused
|
|
62
|
+
one — a stale copy there can carry findings from an earlier run),
|
|
63
|
+
lints the same sub-path there, and drops every current finding whose
|
|
64
|
+
`(file, rule, message)` triple already exists at the baseline. The
|
|
65
|
+
line number is excluded from that triple on purpose: a finding does
|
|
66
|
+
not become "new" just because a line was inserted above it. The
|
|
67
|
+
worktree is removed afterwards regardless of outcome. A ref that
|
|
68
|
+
doesn't resolve, or a `path` outside any git repo, exits 2 with the
|
|
69
|
+
reason on stderr. The report gets a top-level `"baseline": {"ref":
|
|
70
|
+
..., "sha": ...}`; the summary and exit code count only the new
|
|
71
|
+
findings; `--format text` prints only the new findings plus the
|
|
72
|
+
baseline ref on the totals line. Since a fresh worktree has no
|
|
73
|
+
`.venv` of its own, the baseline's ty run is given `--python`
|
|
74
|
+
explicitly — the current run's resolved `ty-environment` Python
|
|
75
|
+
environment, unless you passed your own `--python` — and the report
|
|
76
|
+
names which one it used under `"baseline"`'s `"ty-python"` key.
|
|
77
|
+
`--skip` and `--extra-search-path` apply to both runs.
|
|
78
|
+
- **File-move artifact in the delta.** The baseline keys findings by
|
|
79
|
+
`(file, rule, message)`. A refactor that moves code from `old/x.py`
|
|
80
|
+
to `new/y.py` reports the entry at `old/x.py` as *gone* and the same
|
|
81
|
+
finding at `new/y.py` as *new* — net delta +1 even though the
|
|
82
|
+
codebase's lint posture is unchanged. Two responses: (a) accept the
|
|
83
|
+
+1 and document the move in the commit message; (b) commit and
|
|
84
|
+
refresh the baseline. Prefer (a) for one-off moves; (b) only when a
|
|
85
|
+
move is large enough to otherwise swamp the real new findings.
|
|
86
|
+
- `uv run python-linters --version` — prints this package's version and
|
|
87
|
+
the bundled ruff/ty/pymodularity versions on one line.
|
|
88
|
+
- `uv run python-linters reference ruff|ty` — prints this package's shipped
|
|
89
|
+
reference config for the given tool to stdout. The package never writes
|
|
90
|
+
it into your project — copying it into `ruff.toml`/`ty.toml` (or
|
|
91
|
+
`pyproject.toml`) stays with you.
|
|
92
|
+
- `uv run python-linters check-config ruff|ty <path/to/config>` — compares
|
|
93
|
+
your copy against the pinned tool's current rule set: rules the tool
|
|
94
|
+
knows that your config omits (untriaged), and rules your config names
|
|
95
|
+
that the tool no longer knows (stale). The report names the version of
|
|
96
|
+
the tool it checked against on its first line.
|
|
97
|
+
|
|
98
|
+
## Exit codes
|
|
99
|
+
|
|
100
|
+
- `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr), a
|
|
101
|
+
usage error (e.g. `--skip` naming all 4 tools), or — with
|
|
102
|
+
`--baseline` — a ref that doesn't resolve or a path outside any git
|
|
103
|
+
repo.
|
|
104
|
+
- `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
|
|
105
|
+
failure.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
python_linters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
python_linters/__main__.py,sha256=Xkx-N0S-OWOvqBBgDA0HgCwrW324UrwBEQbR8PUayJI,158
|
|
3
|
+
python_linters/baseline_report.py,sha256=IMDJ9kb8G1hVrN6FJi8tlS8qTD1CThyP5kLeWr6bbAI,7565
|
|
4
|
+
python_linters/cli_commands.py,sha256=ZaLvKjGd8b5aZX7myTmXQHuq98EMrRaLVs77ScQTlgQ,5454
|
|
5
|
+
python_linters/config_check.py,sha256=gFsI8KVBRkY_x7cREdRE3G51se_sfQAMAacTWR7pdxk,14687
|
|
6
|
+
python_linters/lint_report.py,sha256=usr2__SlHcniIhxHrOfm1xOzT631O5NURnDGND9yqsY,17650
|
|
7
|
+
python_linters/reference_files.py,sha256=pD0fGW-PJiV09LFIy9-hZKD7EVmHIUjkE3SfD-NeiI4,764
|
|
8
|
+
python_linters/references/ruff.reference.toml,sha256=Q6kKaeekpeiGcVQoqPDdFfWA-jUij66s8q33EZWWZQ0,29668
|
|
9
|
+
python_linters/references/ty.reference.toml,sha256=AKwhaBhs75QwbtrgqhuyoAYFTQX9Y7ns7TtwF8reQ4E,8244
|
|
10
|
+
python_linters/text_report.py,sha256=L6NRS9tXUoUhEA1ECd94Nzr5S4VKyU8NRCtSRtVV0ik,1814
|
|
11
|
+
python_linters/tool_errors.py,sha256=Fw3byFD0kk8QKJQg85weKB11_CRrnu2h_N2XQId6Abc,1572
|
|
12
|
+
python_linters/tool_versions.py,sha256=9XASpuj1L0CB_0css6yxBuBMWaMCIyq3r6YRRxTvZmE,726
|
|
13
|
+
python_linters-0.2.3.dist-info/WHEEL,sha256=3OmCxFA0ffBIsX4x0o8CdfSvZAOvO3mzEG8U_z5TvV4,80
|
|
14
|
+
python_linters-0.2.3.dist-info/entry_points.txt,sha256=2qemc7DiLCP7wVNw-8KENf-v5L62Jx40tHgVXC0s_FM,65
|
|
15
|
+
python_linters-0.2.3.dist-info/METADATA,sha256=wCV0j0SXk8vbUBonKKKLXFWIWb1ySYB2VKuRM3woYNw,5524
|
|
16
|
+
python_linters-0.2.3.dist-info/RECORD,,
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: python-linters
|
|
3
|
-
Version: 0.2.1
|
|
4
|
-
Summary: Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule.
|
|
5
|
-
Author: Tilo Himmelsbach
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Requires-Dist: ruff==0.17.3
|
|
8
|
-
Requires-Dist: ty==0.0.90
|
|
9
|
-
Requires-Dist: pymodularity==0.5.0
|
|
10
|
-
Requires-Python: >=3.11
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
|
|
13
|
-
# python-linters
|
|
14
|
-
|
|
15
|
-
Run ruff, ty and pymodularity on a Python project and print one JSON report
|
|
16
|
-
grouped by rule, for agents and CI to read.
|
|
17
|
-
|
|
18
|
-
## Install
|
|
19
|
-
|
|
20
|
-
As a dev dependency of the project you want to lint:
|
|
21
|
-
|
|
22
|
-
```
|
|
23
|
-
uv add --dev python-linters
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Commands
|
|
27
|
-
|
|
28
|
-
- `uv run python-linters lint [path] [--out FILE]` — runs ruff, ty and
|
|
29
|
-
pymodularity (default path: `.`) and prints the merged JSON report to
|
|
30
|
-
stdout, or writes it to `FILE` with `--out`.
|
|
31
|
-
- `uv run python-linters reference ruff|ty` — prints this package's shipped
|
|
32
|
-
reference config for the given tool to stdout. The package never writes
|
|
33
|
-
it into your project — copying it into `ruff.toml`/`ty.toml` (or
|
|
34
|
-
`pyproject.toml`) stays with you.
|
|
35
|
-
- `uv run python-linters check-config ruff|ty <path/to/config>` — compares
|
|
36
|
-
your copy against the pinned tool's current rule set: rules the tool
|
|
37
|
-
knows that your config omits (untriaged), and rules your config names
|
|
38
|
-
that the tool no longer knows (stale).
|
|
39
|
-
|
|
40
|
-
## Exit codes
|
|
41
|
-
|
|
42
|
-
- `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr).
|
|
43
|
-
- `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
|
|
44
|
-
failure.
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
python_linters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
-
python_linters/__main__.py,sha256=Xkx-N0S-OWOvqBBgDA0HgCwrW324UrwBEQbR8PUayJI,158
|
|
3
|
-
python_linters/cli_commands.py,sha256=tVneEJJd6bgBrzunto3UJoEJHupwc5wNjMfTGwFCmN0,3122
|
|
4
|
-
python_linters/config_check.py,sha256=gFsI8KVBRkY_x7cREdRE3G51se_sfQAMAacTWR7pdxk,14687
|
|
5
|
-
python_linters/lint_report.py,sha256=5eCrWtZVEKi7Hulbw0VrkKbTnwDav2MuqEo8RmKCKSo,10623
|
|
6
|
-
python_linters/reference_files.py,sha256=pD0fGW-PJiV09LFIy9-hZKD7EVmHIUjkE3SfD-NeiI4,764
|
|
7
|
-
python_linters/references/ruff.reference.toml,sha256=J7uUpDhi6STS3yoUlyO1ZT2dgQLb9gJmFpFScKzupng,29058
|
|
8
|
-
python_linters/references/ty.reference.toml,sha256=674u63jOImBHrY92lAa27ZcilevZj6GSqxWywp6kQTw,7677
|
|
9
|
-
python_linters/tool_errors.py,sha256=Fw3byFD0kk8QKJQg85weKB11_CRrnu2h_N2XQId6Abc,1572
|
|
10
|
-
python_linters-0.2.1.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
11
|
-
python_linters-0.2.1.dist-info/entry_points.txt,sha256=2qemc7DiLCP7wVNw-8KENf-v5L62Jx40tHgVXC0s_FM,65
|
|
12
|
-
python_linters-0.2.1.dist-info/METADATA,sha256=SwxLKHe6IGzYTOX3xETKwesEehJ8DUwiUI470ynXJR4,1497
|
|
13
|
-
python_linters-0.2.1.dist-info/RECORD,,
|
|
File without changes
|