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.
File without changes
@@ -0,0 +1,11 @@
1
+ import sys
2
+
3
+ from python_linters.cli_commands import main as _cli_main
4
+
5
+
6
+ def main() -> None:
7
+ sys.exit(_cli_main())
8
+
9
+
10
+ if __name__ == "__main__":
11
+ main()
@@ -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)