python-linters 0.1.6__tar.gz → 0.2.0__tar.gz
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-0.2.0/PKG-INFO +44 -0
- python_linters-0.2.0/README.md +32 -0
- python_linters-0.2.0/pyproject.toml +32 -0
- python_linters-0.2.0/pyproject.toml.orig +32 -0
- python_linters-0.2.0/src/python_linters/__main__.py +11 -0
- python_linters-0.2.0/src/python_linters/cli_commands.py +103 -0
- python_linters-0.2.0/src/python_linters/config_check.py +413 -0
- python_linters-0.2.0/src/python_linters/lint_report.py +273 -0
- python_linters-0.2.0/src/python_linters/reference_files.py +25 -0
- python_linters-0.2.0/src/python_linters/references/ruff.reference.toml +505 -0
- python_linters-0.2.0/src/python_linters/references/ty.reference.toml +203 -0
- python_linters-0.2.0/src/python_linters/tool_errors.py +44 -0
- python_linters-0.1.6/.gitignore +0 -161
- python_linters-0.1.6/LICENSE +0 -21
- python_linters-0.1.6/PKG-INFO +0 -214
- python_linters-0.1.6/README.md +0 -199
- python_linters-0.1.6/pyproject.toml +0 -40
- python_linters-0.1.6/python_linters/add_noqa.py +0 -32
- python_linters-0.1.6/python_linters/config_files.py +0 -9
- python_linters-0.1.6/python_linters/extending_ruff_toml.py +0 -37
- python_linters-0.1.6/python_linters/fix_code.py +0 -25
- python_linters-0.1.6/python_linters/getting_to_be_linted_folders.py +0 -42
- python_linters-0.1.6/python_linters/parse_rules.py +0 -16
- python_linters-0.1.6/python_linters/pyrightconfig.json +0 -25
- python_linters-0.1.6/python_linters/ruff.toml +0 -158
- python_linters-0.1.6/python_linters/ruff_rule_categories.py +0 -80
- python_linters-0.1.6/python_linters/ruff_rules.txt +0 -1182
- python_linters-0.1.6/python_linters/run_linters.py +0 -49
- python_linters-0.1.6/python_linters/utils.py +0 -72
- {python_linters-0.1.6 → python_linters-0.2.0/src}/python_linters/__init__.py +0 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-linters
|
|
3
|
+
Version: 0.2.0
|
|
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.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# python-linters
|
|
2
|
+
|
|
3
|
+
Run ruff, ty and pymodularity on a Python project and print one JSON report
|
|
4
|
+
grouped by rule, for agents and CI to read.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
As a dev dependency of the project you want to lint:
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
uv add --dev python-linters
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
- `uv run python-linters lint [path] [--out FILE]` — runs ruff, ty and
|
|
17
|
+
pymodularity (default path: `.`) and prints the merged JSON report to
|
|
18
|
+
stdout, or writes it to `FILE` with `--out`.
|
|
19
|
+
- `uv run python-linters reference ruff|ty` — prints this package's shipped
|
|
20
|
+
reference config for the given tool to stdout. The package never writes
|
|
21
|
+
it into your project — copying it into `ruff.toml`/`ty.toml` (or
|
|
22
|
+
`pyproject.toml`) stays with you.
|
|
23
|
+
- `uv run python-linters check-config ruff|ty <path/to/config>` — compares
|
|
24
|
+
your copy against the pinned tool's current rule set: rules the tool
|
|
25
|
+
knows that your config omits (untriaged), and rules your config names
|
|
26
|
+
that the tool no longer knows (stale).
|
|
27
|
+
|
|
28
|
+
## Exit codes
|
|
29
|
+
|
|
30
|
+
- `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr).
|
|
31
|
+
- `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
|
|
32
|
+
failure.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "python-linters"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"ruff==0.17.3",
|
|
10
|
+
"ty==0.0.90",
|
|
11
|
+
"pymodularity==0.5.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[project.authors]]
|
|
15
|
+
name = "Tilo Himmelsbach"
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
python-linters = "python_linters.__main__:main"
|
|
19
|
+
|
|
20
|
+
[tool.deptry.per_rule_ignores]
|
|
21
|
+
DEP002 = [
|
|
22
|
+
"ruff",
|
|
23
|
+
"ty",
|
|
24
|
+
"pymodularity",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = ["pytest"]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "python-linters"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
authors = [{ name = "Tilo Himmelsbach" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"ruff==0.17.3",
|
|
11
|
+
"ty==0.0.90",
|
|
12
|
+
"pymodularity==0.5.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
python-linters = "python_linters.__main__:main"
|
|
17
|
+
|
|
18
|
+
[tool.deptry.per_rule_ignores]
|
|
19
|
+
# DEP002 (unused-dependency) false positive: deptry only sees `import` statements,
|
|
20
|
+
# but this package runs ruff, ty and pymodularity as `sys.executable -m <tool>`
|
|
21
|
+
# subprocesses, never `import`s them. All three are genuinely required at
|
|
22
|
+
# runtime for `python-linters lint` to work.
|
|
23
|
+
DEP002 = ["ruff", "ty", "pymodularity"]
|
|
24
|
+
|
|
25
|
+
[dependency-groups]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[build-system]
|
|
31
|
+
requires = ["uv_build>=0.10.7,<0.11.0"]
|
|
32
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from python_linters.config_check import (
|
|
9
|
+
check_ruff_config,
|
|
10
|
+
check_ty_config,
|
|
11
|
+
format_ruff_report,
|
|
12
|
+
format_ty_report,
|
|
13
|
+
)
|
|
14
|
+
from python_linters.lint_report import build_report
|
|
15
|
+
from python_linters.reference_files import reference_text
|
|
16
|
+
from python_linters.tool_errors import ToolFailedError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _lint(args: argparse.Namespace) -> int:
|
|
20
|
+
project_path = Path(args.path).resolve()
|
|
21
|
+
if not project_path.exists():
|
|
22
|
+
print(f"Error: {project_path} does not exist", file=sys.stderr)
|
|
23
|
+
return 2
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
output, total_findings = build_report(project_path)
|
|
27
|
+
except ToolFailedError as e:
|
|
28
|
+
print(str(e), file=sys.stderr)
|
|
29
|
+
return 2
|
|
30
|
+
|
|
31
|
+
serialized = json.dumps(output, indent=2)
|
|
32
|
+
|
|
33
|
+
if args.out:
|
|
34
|
+
Path(args.out).write_text(serialized, encoding="utf-8")
|
|
35
|
+
print(f"Written to {args.out}", file=sys.stderr)
|
|
36
|
+
else:
|
|
37
|
+
print(serialized)
|
|
38
|
+
|
|
39
|
+
return 1 if total_findings else 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _reference(args: argparse.Namespace) -> int:
|
|
43
|
+
print(reference_text(args.tool), end="")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _run_config_check(tool: str, config_path: Path) -> bool:
|
|
48
|
+
"""Run and print the config check for one tool. Returns whether it's clean."""
|
|
49
|
+
if tool == "ruff":
|
|
50
|
+
check = check_ruff_config(config_path)
|
|
51
|
+
print(format_ruff_report(config_path, check))
|
|
52
|
+
else:
|
|
53
|
+
check = check_ty_config(config_path)
|
|
54
|
+
print(format_ty_report(config_path, check))
|
|
55
|
+
return check.is_clean
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_config(args: argparse.Namespace) -> int:
|
|
59
|
+
config_path = Path(args.config)
|
|
60
|
+
if not config_path.exists():
|
|
61
|
+
print(f"Error: {config_path} not found", file=sys.stderr)
|
|
62
|
+
return 2
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
is_clean = _run_config_check(args.tool, config_path)
|
|
66
|
+
except ToolFailedError as e:
|
|
67
|
+
print(str(e), file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
|
|
70
|
+
return 0 if is_clean else 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
74
|
+
parser = argparse.ArgumentParser(prog="python-linters")
|
|
75
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
76
|
+
|
|
77
|
+
lint_parser = subparsers.add_parser(
|
|
78
|
+
"lint", help="run ruff, ty and pymodularity and print one JSON report"
|
|
79
|
+
)
|
|
80
|
+
lint_parser.add_argument("path", nargs="?", default=".")
|
|
81
|
+
lint_parser.add_argument("--out", default=None)
|
|
82
|
+
lint_parser.set_defaults(func=_lint)
|
|
83
|
+
|
|
84
|
+
reference_parser = subparsers.add_parser(
|
|
85
|
+
"reference", help="print a shipped reference config to stdout"
|
|
86
|
+
)
|
|
87
|
+
reference_parser.add_argument("tool", choices=["ruff", "ty"])
|
|
88
|
+
reference_parser.set_defaults(func=_reference)
|
|
89
|
+
|
|
90
|
+
check_config_parser = subparsers.add_parser(
|
|
91
|
+
"check-config", help="compare a config against the pinned tool's current rules"
|
|
92
|
+
)
|
|
93
|
+
check_config_parser.add_argument("tool", choices=["ruff", "ty"])
|
|
94
|
+
check_config_parser.add_argument("config")
|
|
95
|
+
check_config_parser.set_defaults(func=_check_config)
|
|
96
|
+
|
|
97
|
+
return parser
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main(argv: list[str] | None = None) -> int:
|
|
101
|
+
parser = build_parser()
|
|
102
|
+
args = parser.parse_args(argv)
|
|
103
|
+
return args.func(args)
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Compare a ruff or ty config against the pinned tool's current rule set.
|
|
2
|
+
|
|
3
|
+
Ported from check_ruff_rules.py and check_ty_rules.py (quality-and-security-tools
|
|
4
|
+
howtos). Both tools run as `sys.executable -m <tool>` — the python-linters
|
|
5
|
+
environment's own pinned version, same resolution as lint_report.py — so
|
|
6
|
+
check_ty_rules.py's `--ty-version` flag (for checking a reference file
|
|
7
|
+
against a version the howto repo doesn't pin) is dropped: python-linters
|
|
8
|
+
always pins its own ty.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import string
|
|
16
|
+
import sys
|
|
17
|
+
import tomllib
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from python_linters.tool_errors import run_checked
|
|
23
|
+
|
|
24
|
+
# ── ruff ──────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RuffConfigCheck:
|
|
29
|
+
version: str
|
|
30
|
+
category_count: int
|
|
31
|
+
selected_count: int
|
|
32
|
+
commented_count: int
|
|
33
|
+
new_categories: list[tuple[str, str]]
|
|
34
|
+
removed: list[str]
|
|
35
|
+
untriaged_codeless: list[str]
|
|
36
|
+
untriaged_preview: list[tuple[str, str]]
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_clean(self) -> bool:
|
|
40
|
+
return not (
|
|
41
|
+
self.new_categories
|
|
42
|
+
or self.removed
|
|
43
|
+
or self.untriaged_codeless
|
|
44
|
+
or self.untriaged_preview
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_ruff_rules() -> list[dict[str, Any]]:
|
|
49
|
+
result = run_checked(
|
|
50
|
+
[sys.executable, "-m", "ruff", "rule", "--all", "--output-format", "json"],
|
|
51
|
+
Path.cwd(),
|
|
52
|
+
"ruff",
|
|
53
|
+
)
|
|
54
|
+
return json.loads(result.stdout)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_ruff_version() -> str:
|
|
58
|
+
result = run_checked([sys.executable, "-m", "ruff", "version"], Path.cwd(), "ruff")
|
|
59
|
+
return result.stdout.strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _get_ruff_categories(rules: list[dict[str, Any]]) -> dict[str, str]:
|
|
63
|
+
"""All rule categories (prefix -> one representative linter name, for display)."""
|
|
64
|
+
categories: dict[str, str] = {}
|
|
65
|
+
for r in rules:
|
|
66
|
+
code = r["code"]
|
|
67
|
+
if code is None:
|
|
68
|
+
continue
|
|
69
|
+
prefix = code.rstrip(string.digits)
|
|
70
|
+
if prefix not in categories:
|
|
71
|
+
categories[prefix] = r["linter"]
|
|
72
|
+
return categories
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_category_linters(rules: list[dict[str, Any]]) -> dict[str, set[str]]:
|
|
76
|
+
"""Every rule category (prefix) -> the full set of linters under it.
|
|
77
|
+
|
|
78
|
+
A single rstrip(digits) prefix can hold rules from more than one ruff
|
|
79
|
+
linter, e.g. "C" holds both flake8-comprehensions and mccabe, "T" holds
|
|
80
|
+
both flake8-debugger and flake8-print. Those prefixes behave like "PL"
|
|
81
|
+
(which spans PLC/PLE/PLR/PLW): selecting them selects several linters
|
|
82
|
+
at once. A prefix with exactly one linter does not: selecting "N"
|
|
83
|
+
(pep8-naming) must not be read as covering "NPY" (NumPy-specific rules)
|
|
84
|
+
just because "NPY" starts with "N".
|
|
85
|
+
"""
|
|
86
|
+
category_linters: dict[str, set[str]] = {}
|
|
87
|
+
for r in rules:
|
|
88
|
+
code = r["code"]
|
|
89
|
+
if code is None:
|
|
90
|
+
continue
|
|
91
|
+
prefix = code.rstrip(string.digits)
|
|
92
|
+
category_linters.setdefault(prefix, set()).add(r["linter"])
|
|
93
|
+
return category_linters
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _get_codeless_rules(rules: list[dict[str, Any]]) -> list[str]:
|
|
97
|
+
"""Names of rules without a code (`"code": null`, e.g. pytest-fixture-autouse).
|
|
98
|
+
|
|
99
|
+
No prefix selects them, so only a name in select (or `ALL`) enables them.
|
|
100
|
+
"""
|
|
101
|
+
return sorted(r["name"] for r in rules if r["code"] is None)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _parse_toml_lint(toml_path: Path) -> dict[str, Any]:
|
|
105
|
+
"""Read the lint table from ruff.toml or pyproject.toml.
|
|
106
|
+
|
|
107
|
+
tomllib, not a regex: a `]` inside a comment (e.g. `[lint.flake8-tidy-imports]`)
|
|
108
|
+
ended a regex-matched array early.
|
|
109
|
+
"""
|
|
110
|
+
data = tomllib.loads(toml_path.read_text(encoding="utf-8"))
|
|
111
|
+
ruff = data.get("tool", {}).get("ruff", data)
|
|
112
|
+
lint = dict(ruff.get("lint", {}))
|
|
113
|
+
lint.setdefault("select", ruff.get("select", []))
|
|
114
|
+
lint.setdefault("ignore", ruff.get("ignore", []))
|
|
115
|
+
return lint
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_toml_comments(toml_path: Path) -> set[str]:
|
|
119
|
+
"""Find excluded categories, rule prefixes and code-less rule names.
|
|
120
|
+
|
|
121
|
+
Expects lines like: # excluded "ERA", # eradicate
|
|
122
|
+
A line may list several entries before its reason: # excluded "E11", "E2", # why
|
|
123
|
+
"""
|
|
124
|
+
content = toml_path.read_text(encoding="utf-8")
|
|
125
|
+
entries: set[str] = set()
|
|
126
|
+
for listed in re.findall(r"^#\s*excluded\s+(.*)$", content, re.MULTILINE):
|
|
127
|
+
entries.update(re.findall(r'"([^"]+)"', listed.split("#", 1)[0]))
|
|
128
|
+
return entries
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _covers(
|
|
132
|
+
selector: str,
|
|
133
|
+
category: str,
|
|
134
|
+
category_linters: dict[str, set[str]],
|
|
135
|
+
meta_prefixes: set[str],
|
|
136
|
+
) -> bool:
|
|
137
|
+
"""True iff a selected/excluded `selector` covers rule `category`.
|
|
138
|
+
|
|
139
|
+
Ruff's own prefix resolution: a selector covers only categories of its
|
|
140
|
+
own linter(s). Equal is always covered. A strict extension is covered
|
|
141
|
+
only if `selector` is a meta prefix (spans several linters, e.g. "PL",
|
|
142
|
+
"C", "T") or `selector` and `category` share the same linter set.
|
|
143
|
+
"""
|
|
144
|
+
if category == selector:
|
|
145
|
+
return True
|
|
146
|
+
if not category.startswith(selector):
|
|
147
|
+
return False
|
|
148
|
+
if selector in meta_prefixes:
|
|
149
|
+
return True
|
|
150
|
+
return category_linters.get(selector) == category_linters.get(category)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _covers_rule(
|
|
154
|
+
selector: str,
|
|
155
|
+
code: str,
|
|
156
|
+
linter: str,
|
|
157
|
+
rules: list[dict[str, Any]],
|
|
158
|
+
meta_prefixes: set[str],
|
|
159
|
+
) -> bool:
|
|
160
|
+
"""Same rule as `_covers`, for one concrete rule code rather than a category.
|
|
161
|
+
|
|
162
|
+
`code` (e.g. "C416") and partial selectors like "C90" are not category
|
|
163
|
+
keys (only the blended rstrip prefix "C" is), so `selector`'s own
|
|
164
|
+
linter set is computed straight from the rules it actually matches.
|
|
165
|
+
"""
|
|
166
|
+
if code == selector:
|
|
167
|
+
return True
|
|
168
|
+
if not code.startswith(selector):
|
|
169
|
+
return False
|
|
170
|
+
if selector in meta_prefixes:
|
|
171
|
+
return True
|
|
172
|
+
selector_linters = {r["linter"] for r in rules if r["code"] and r["code"].startswith(selector)}
|
|
173
|
+
return selector_linters == {linter}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_untriaged_preview(
|
|
177
|
+
rules: list[dict[str, Any]],
|
|
178
|
+
selected: set[str],
|
|
179
|
+
ignored: set[str],
|
|
180
|
+
excluded: set[str],
|
|
181
|
+
) -> list[tuple[str, str]]:
|
|
182
|
+
"""Preview rules a selected category contains but nobody triaged.
|
|
183
|
+
|
|
184
|
+
Triaged means: named (or coded) in select or ignore, or covered by an
|
|
185
|
+
`# excluded` prefix.
|
|
186
|
+
"""
|
|
187
|
+
rule_names = {r["name"] for r in rules}
|
|
188
|
+
category_selects = selected - rule_names
|
|
189
|
+
named = selected | ignored
|
|
190
|
+
untriaged = []
|
|
191
|
+
for r in rules:
|
|
192
|
+
code, name = r["code"], r["name"]
|
|
193
|
+
if not r.get("preview") or code is None or name is None:
|
|
194
|
+
continue
|
|
195
|
+
if not any(code.startswith(p) for p in category_selects):
|
|
196
|
+
continue
|
|
197
|
+
if name in named or code in named:
|
|
198
|
+
continue
|
|
199
|
+
if any(code.startswith(p) for p in excluded):
|
|
200
|
+
continue
|
|
201
|
+
untriaged.append((code, name))
|
|
202
|
+
return sorted(untriaged)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get_new_categories(
|
|
206
|
+
rules: list[dict[str, Any]],
|
|
207
|
+
categories: dict[str, str],
|
|
208
|
+
category_linters: dict[str, set[str]],
|
|
209
|
+
known: set[str],
|
|
210
|
+
meta_prefixes: set[str],
|
|
211
|
+
) -> list[tuple[str, str]]:
|
|
212
|
+
"""Categories no selector in `known` triages.
|
|
213
|
+
|
|
214
|
+
A multi-linter prefix (e.g. "C" = flake8-comprehensions + mccabe) can be
|
|
215
|
+
fully triaged by several single-linter selectors together (e.g. "C4" +
|
|
216
|
+
"C90"), without any selector covering the blended prefix itself. So a
|
|
217
|
+
category only stays "new" if some of its own rules are left uncovered
|
|
218
|
+
by every selector in known, checked rule by rule.
|
|
219
|
+
"""
|
|
220
|
+
new_categories = []
|
|
221
|
+
for prefix, linter in sorted(categories.items()):
|
|
222
|
+
if any(_covers(k, prefix, category_linters, meta_prefixes) for k in known):
|
|
223
|
+
continue
|
|
224
|
+
prefix_rules = [r for r in rules if r["code"] and r["code"].rstrip(string.digits) == prefix]
|
|
225
|
+
if all(
|
|
226
|
+
any(_covers_rule(k, r["code"], r["linter"], rules, meta_prefixes) for k in known)
|
|
227
|
+
for r in prefix_rules
|
|
228
|
+
):
|
|
229
|
+
continue
|
|
230
|
+
new_categories.append((prefix, linter))
|
|
231
|
+
return new_categories
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _get_removed(rules: list[dict[str, Any]], selected: set[str]) -> list[str]:
|
|
235
|
+
"""Select entries that are neither a rule name nor a prefix of any real rule code.
|
|
236
|
+
|
|
237
|
+
A misspelt name lands here: ruff itself only warns.
|
|
238
|
+
"""
|
|
239
|
+
rule_names = {r["name"] for r in rules}
|
|
240
|
+
rule_codes = [r["code"] for r in rules if r["code"] is not None]
|
|
241
|
+
return [
|
|
242
|
+
code
|
|
243
|
+
for code in sorted(selected)
|
|
244
|
+
if code not in rule_names and not any(c.startswith(code) for c in rule_codes)
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def check_ruff_config(toml_path: Path) -> RuffConfigCheck:
|
|
249
|
+
version = _get_ruff_version()
|
|
250
|
+
rules = _get_ruff_rules()
|
|
251
|
+
categories = _get_ruff_categories(rules)
|
|
252
|
+
category_linters = _get_category_linters(rules)
|
|
253
|
+
lint = _parse_toml_lint(toml_path)
|
|
254
|
+
selected = set(lint["select"])
|
|
255
|
+
commented = _parse_toml_comments(toml_path)
|
|
256
|
+
|
|
257
|
+
known = selected | commented
|
|
258
|
+
meta_prefixes = {"PL"} | {p for p, linters in category_linters.items() if len(linters) > 1}
|
|
259
|
+
|
|
260
|
+
new_categories = _get_new_categories(rules, categories, category_linters, known, meta_prefixes)
|
|
261
|
+
removed = _get_removed(rules, selected)
|
|
262
|
+
|
|
263
|
+
untriaged_codeless = [n for n in _get_codeless_rules(rules) if n not in known]
|
|
264
|
+
|
|
265
|
+
untriaged_preview = []
|
|
266
|
+
if lint.get("explicit-preview-rules"):
|
|
267
|
+
untriaged_preview = _get_untriaged_preview(
|
|
268
|
+
rules, selected, set(lint["ignore"]), commented
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return RuffConfigCheck(
|
|
272
|
+
version=version,
|
|
273
|
+
category_count=len(categories),
|
|
274
|
+
selected_count=len(selected),
|
|
275
|
+
commented_count=len(commented),
|
|
276
|
+
new_categories=new_categories,
|
|
277
|
+
removed=removed,
|
|
278
|
+
untriaged_codeless=untriaged_codeless,
|
|
279
|
+
untriaged_preview=untriaged_preview,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def format_ruff_report(toml_path: Path, check: RuffConfigCheck) -> str:
|
|
284
|
+
lines = [
|
|
285
|
+
f"Ruff version: {check.version}",
|
|
286
|
+
f"Config: {toml_path}",
|
|
287
|
+
f"Categories in ruff: {check.category_count}",
|
|
288
|
+
f"Selected in config: {check.selected_count}",
|
|
289
|
+
f"Commented/excluded: {check.commented_count}",
|
|
290
|
+
"",
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
if check.new_categories:
|
|
294
|
+
lines.append(f"NEW categories not yet triaged ({len(check.new_categories)}):")
|
|
295
|
+
lines.append(" Add to select or comment out with '# excluded' in ruff.toml:\n")
|
|
296
|
+
lines.extend(f' "{prefix}",\t# {linter}' for prefix, linter in check.new_categories)
|
|
297
|
+
lines.append("")
|
|
298
|
+
|
|
299
|
+
if check.removed:
|
|
300
|
+
lines.append(f"REMOVED/RENAMED categories still in select ({len(check.removed)}):")
|
|
301
|
+
lines.extend(f" {code}" for code in check.removed)
|
|
302
|
+
lines.append("")
|
|
303
|
+
|
|
304
|
+
if check.untriaged_codeless:
|
|
305
|
+
lines.append(f"Code-less rules not yet triaged ({len(check.untriaged_codeless)}):")
|
|
306
|
+
lines.append(" Add to select by name or comment out with '# excluded' in ruff.toml:\n")
|
|
307
|
+
lines.extend(f' "{name}",' for name in check.untriaged_codeless)
|
|
308
|
+
lines.append("")
|
|
309
|
+
|
|
310
|
+
if check.untriaged_preview:
|
|
311
|
+
lines.append(f"Preview rules not yet triaged ({len(check.untriaged_preview)}):")
|
|
312
|
+
lines.append(" Add to select by name or cover with an '# excluded' line in ruff.toml:\n")
|
|
313
|
+
lines.extend(f' "{name}",\t# {code}' for code, name in check.untriaged_preview)
|
|
314
|
+
lines.append("")
|
|
315
|
+
|
|
316
|
+
if check.is_clean:
|
|
317
|
+
lines.append("All categories accounted for. Config is up to date.")
|
|
318
|
+
|
|
319
|
+
return "\n".join(lines)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── ty ────────────────────────────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class TyConfigCheck:
|
|
327
|
+
version: str
|
|
328
|
+
rule_count: int
|
|
329
|
+
named_count: int
|
|
330
|
+
untriaged: list[str]
|
|
331
|
+
default_levels: dict[str, str]
|
|
332
|
+
stale: list[str]
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def is_clean(self) -> bool:
|
|
336
|
+
return not (self.untriaged or self.stale)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _get_ty_rules() -> dict[str, str]:
|
|
340
|
+
"""Ask ty for every rule it knows (name -> default level)."""
|
|
341
|
+
result = run_checked(
|
|
342
|
+
[sys.executable, "-m", "ty", "explain", "rule", "--output-format", "json"],
|
|
343
|
+
Path.cwd(),
|
|
344
|
+
"ty",
|
|
345
|
+
)
|
|
346
|
+
rules = json.loads(result.stdout)
|
|
347
|
+
return {r["name"]: r["default_level"] for r in rules}
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _get_ty_version() -> str:
|
|
351
|
+
result = run_checked([sys.executable, "-m", "ty", "--version"], Path.cwd(), "ty")
|
|
352
|
+
return result.stdout.strip()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _parse_toml_rules(config_path: Path) -> set[str]:
|
|
356
|
+
"""Read the rule table from ty.toml (`[rules]`) or pyproject.toml (`[tool.ty.rules]`).
|
|
357
|
+
|
|
358
|
+
tomllib, not a regex, for the same reason check_ruff_config uses it:
|
|
359
|
+
a rule name could appear inside a comment without this breaking.
|
|
360
|
+
"""
|
|
361
|
+
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
362
|
+
if "tool" in data:
|
|
363
|
+
rules = data.get("tool", {}).get("ty", {}).get("rules", {})
|
|
364
|
+
else:
|
|
365
|
+
rules = data.get("rules", {})
|
|
366
|
+
return set(rules.keys())
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def check_ty_config(config_path: Path) -> TyConfigCheck:
|
|
370
|
+
version = _get_ty_version()
|
|
371
|
+
rules = _get_ty_rules()
|
|
372
|
+
named = _parse_toml_rules(config_path)
|
|
373
|
+
|
|
374
|
+
known = set(rules)
|
|
375
|
+
untriaged = sorted(known - named)
|
|
376
|
+
stale = sorted(named - known)
|
|
377
|
+
|
|
378
|
+
return TyConfigCheck(
|
|
379
|
+
version=version,
|
|
380
|
+
rule_count=len(known),
|
|
381
|
+
named_count=len(named),
|
|
382
|
+
untriaged=untriaged,
|
|
383
|
+
default_levels=rules,
|
|
384
|
+
stale=stale,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def format_ty_report(config_path: Path, check: TyConfigCheck) -> str:
|
|
389
|
+
lines = [
|
|
390
|
+
f"ty version: {check.version}",
|
|
391
|
+
f"Config: {config_path}",
|
|
392
|
+
f"Rules in ty: {check.rule_count}",
|
|
393
|
+
f"Named in config: {check.named_count}",
|
|
394
|
+
"",
|
|
395
|
+
]
|
|
396
|
+
|
|
397
|
+
if check.untriaged:
|
|
398
|
+
lines.append(f"UNTRIAGED rules ty knows that the config omits ({len(check.untriaged)}):")
|
|
399
|
+
lines.extend(
|
|
400
|
+
f' {name} = "???" # default: {check.default_levels[name]}'
|
|
401
|
+
for name in check.untriaged
|
|
402
|
+
)
|
|
403
|
+
lines.append("")
|
|
404
|
+
|
|
405
|
+
if check.stale:
|
|
406
|
+
lines.append(f"STALE rules named in config that ty no longer knows ({len(check.stale)}):")
|
|
407
|
+
lines.extend(f" {name}" for name in check.stale)
|
|
408
|
+
lines.append("")
|
|
409
|
+
|
|
410
|
+
if check.is_clean:
|
|
411
|
+
lines.append("All rules accounted for. Config is up to date.")
|
|
412
|
+
|
|
413
|
+
return "\n".join(lines)
|