python-linters 0.2.1__tar.gz → 0.2.3__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.
@@ -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,93 @@
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] [--format json|text]
17
+ [--skip TOOL]... [--python PATH] [--extra-search-path DIR]...
18
+ [--baseline REF]` — runs
19
+ ruff, `ruff format --check`, ty and pymodularity (default path: `.`) and
20
+ prints the merged report to stdout, or writes it to `FILE` with `--out`.
21
+ - `--format text` prints one line per finding (`file:line:col
22
+ tool/rule message`, sorted by file then line) with a totals line
23
+ last, instead of the default JSON. `--out` writes whichever format
24
+ was chosen.
25
+ - `--skip TOOL` (repeatable; `ruff`, `ruff-format`, `ty`,
26
+ `pymodularity`) drops that tool: it does not run, gets no section and
27
+ no summary entry, and is listed under the report's top-level
28
+ `"skipped"`. Skipping all 4 is a usage error.
29
+ - `--python PATH` and `--extra-search-path DIR` (repeatable) are
30
+ forwarded verbatim to `ty check`'s own `--python`/`--extra-search-path`.
31
+ Needed whenever the project's imports do not resolve from a `.venv`
32
+ under the linted path — e.g. a ROS 2 package that resolves `rclpy`
33
+ and friends from a system interpreter and `/opt/ros/...`.
34
+ - ty looks for a `.venv` at *its own project root* — the directory
35
+ holding the nearest `ty.toml`, or a `pyproject.toml` with a
36
+ `[tool.ty]` section — not at the path you pass to `lint`. A shared
37
+ parent config can point ty's `.venv` search at the wrong directory for
38
+ a sub-package; the report's `"ty-environment"` key (below) shows what
39
+ ty actually resolved.
40
+ - ty ranks `$PYTHONPATH` entries ahead of first-party code. If
41
+ `PYTHONPATH` is set, a stale installed copy on it can shadow the
42
+ project tree being linted and silently report 0 findings; the report
43
+ names it under `"ty-environment"` when set.
44
+ - the report's top-level `"tools"` key names the bundled ruff/ty/
45
+ pymodularity versions; `"ty-environment"` names ty's resolved project
46
+ root and Python environment (absent if `ty` is skipped).
47
+ - `--baseline REF` (a branch, tag, SHA, `HEAD~N`, ...) reports only
48
+ findings that are new since that git ref: it checks the ref out into a
49
+ fresh `git worktree add --detach` scratch directory (never a reused
50
+ one — a stale copy there can carry findings from an earlier run),
51
+ lints the same sub-path there, and drops every current finding whose
52
+ `(file, rule, message)` triple already exists at the baseline. The
53
+ line number is excluded from that triple on purpose: a finding does
54
+ not become "new" just because a line was inserted above it. The
55
+ worktree is removed afterwards regardless of outcome. A ref that
56
+ doesn't resolve, or a `path` outside any git repo, exits 2 with the
57
+ reason on stderr. The report gets a top-level `"baseline": {"ref":
58
+ ..., "sha": ...}`; the summary and exit code count only the new
59
+ findings; `--format text` prints only the new findings plus the
60
+ baseline ref on the totals line. Since a fresh worktree has no
61
+ `.venv` of its own, the baseline's ty run is given `--python`
62
+ explicitly — the current run's resolved `ty-environment` Python
63
+ environment, unless you passed your own `--python` — and the report
64
+ names which one it used under `"baseline"`'s `"ty-python"` key.
65
+ `--skip` and `--extra-search-path` apply to both runs.
66
+ - **File-move artifact in the delta.** The baseline keys findings by
67
+ `(file, rule, message)`. A refactor that moves code from `old/x.py`
68
+ to `new/y.py` reports the entry at `old/x.py` as *gone* and the same
69
+ finding at `new/y.py` as *new* — net delta +1 even though the
70
+ codebase's lint posture is unchanged. Two responses: (a) accept the
71
+ +1 and document the move in the commit message; (b) commit and
72
+ refresh the baseline. Prefer (a) for one-off moves; (b) only when a
73
+ move is large enough to otherwise swamp the real new findings.
74
+ - `uv run python-linters --version` — prints this package's version and
75
+ the bundled ruff/ty/pymodularity versions on one line.
76
+ - `uv run python-linters reference ruff|ty` — prints this package's shipped
77
+ reference config for the given tool to stdout. The package never writes
78
+ it into your project — copying it into `ruff.toml`/`ty.toml` (or
79
+ `pyproject.toml`) stays with you.
80
+ - `uv run python-linters check-config ruff|ty <path/to/config>` — compares
81
+ your copy against the pinned tool's current rule set: rules the tool
82
+ knows that your config omits (untriaged), and rules your config names
83
+ that the tool no longer knows (stale). The report names the version of
84
+ the tool it checked against on its first line.
85
+
86
+ ## Exit codes
87
+
88
+ - `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr), a
89
+ usage error (e.g. `--skip` naming all 4 tools), or — with
90
+ `--baseline` — a ref that doesn't resolve or a path outside any git
91
+ repo.
92
+ - `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
93
+ failure.
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "python-linters"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.11"
8
8
  dependencies = [
9
- "ruff==0.17.3",
10
- "ty==0.0.90",
9
+ "ruff==0.17.5",
10
+ "ty==0.0.103",
11
11
  "pymodularity==0.5.0",
12
12
  ]
13
13
 
@@ -1,14 +1,14 @@
1
1
  [project]
2
2
  name = "python-linters"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule."
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  requires-python = ">=3.11"
8
8
  authors = [{ name = "Tilo Himmelsbach" }]
9
9
  dependencies = [
10
- "ruff==0.17.3",
11
- "ty==0.0.90",
10
+ "ruff==0.17.5",
11
+ "ty==0.0.103",
12
12
  "pymodularity==0.5.0",
13
13
  ]
14
14
 
@@ -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
@@ -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
- output, total_findings = build_report(project_path)
27
- except ToolFailedError as e:
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 = json.dumps(output, indent=2)
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.set_defaults(func=_lint)
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"
@@ -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.3 (pinned in python-linters' own pyproject.toml) is what
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 run_ty(project_path: Path) -> dict[str, FindingGroup]:
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
- [sys.executable, "-m", "ty", "check", "--output-format", "gitlab", "."],
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
- def build_report(project_path: Path) -> tuple[dict[str, object], int]:
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
- violations = run(project_path)
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 = {"project": str(project_path), "summary": summary, **sections}
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.3
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.3"
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.3.
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 (25)
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 (18)
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 (1): a context manager's cleanup is delayed until the
266
- # generator closes, when `await` is no longer allowed
267
- "yield-in-context-manager-in-async-generator",
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-undocumented-public-function",
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-undocumented-public-function",
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.90 knows is
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.90 (140 rules: 97 error-by-default,
20
- # 25 warn-by-default, 18 ignore-by-default).
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-statement = "ignore"
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-abstract-method = "error"
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-concatenated-string-type-annotation = "error"
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
- unresolved-attribute = "error"
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}
@@ -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,32 +0,0 @@
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.