python-linters 0.2.2__py3-none-any.whl → 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"
@@ -5,6 +5,13 @@ Ported from lint_grouped.py (quality-and-security-tools howtos), minus eslint.
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},
10
17
  "ruff-format": {"total": 2, "rules": 1},
@@ -41,6 +48,10 @@ Output structure:
41
48
  }
42
49
 
43
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.
44
55
  """
45
56
 
46
57
  from __future__ import annotations
@@ -54,6 +65,7 @@ from pathlib import Path
54
65
  from typing import TYPE_CHECKING
55
66
 
56
67
  from python_linters.tool_errors import ToolFailedError, run_checked
68
+ from python_linters.tool_versions import tool_versions
57
69
 
58
70
  if TYPE_CHECKING:
59
71
  from collections.abc import Iterable
@@ -64,6 +76,24 @@ _TY_SEVERITY_MAP = {"major": "error", "minor": "warning", "info": "info"}
64
76
  # --> my_package/main_module.py:1:2
65
77
  _RUFF_FORMAT_LOCATION_RE = re.compile(r"^ --> (?P<file>.+):(?P<line>\d+):(?P<col>\d+)$")
66
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
+
67
97
 
68
98
  @dataclass
69
99
  class Location:
@@ -220,7 +250,49 @@ def run_ruff_format(project_path: Path) -> dict[str, FindingGroup]:
220
250
  return groups
221
251
 
222
252
 
223
- def run_ty(project_path: Path) -> dict[str, FindingGroup]:
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]]:
224
296
  # Resolution: `sys.executable -m ty` from the python-linters
225
297
  # environment, never a bare `ty` entry point (that one falls through to
226
298
  # PATH when the environment has no ty on it) and never a PATH `ty`.
@@ -247,16 +319,35 @@ def run_ty(project_path: Path) -> dict[str, FindingGroup]:
247
319
  # VIRTUAL_ENV here makes ty fall through to discovering `.venv` under
248
320
  # project_path itself. In dev-dependency mode this is a no-op: there
249
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.
250
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
+
251
340
  result = run_checked(
252
- [sys.executable, "-m", "ty", "check", "--output-format", "gitlab", "."],
341
+ cmd,
253
342
  project_path,
254
343
  "ty",
255
344
  ok_codes=(0, 1),
256
345
  env=ty_env,
257
346
  )
347
+ ty_environment = _parse_ty_environment(result.stderr)
348
+
258
349
  if not result.stdout.strip():
259
- return {}
350
+ return {}, ty_environment
260
351
 
261
352
  diagnostics = json.loads(result.stdout)
262
353
 
@@ -287,7 +378,7 @@ def run_ty(project_path: Path) -> dict[str, FindingGroup]:
287
378
  )
288
379
  yield rule, None, severity, location
289
380
 
290
- return _group_findings(findings())
381
+ return _group_findings(findings()), ty_environment
291
382
 
292
383
 
293
384
  def run_pymodularity(project_path: Path) -> dict[str, FindingGroup]:
@@ -333,20 +424,38 @@ def run_pymodularity(project_path: Path) -> dict[str, FindingGroup]:
333
424
  return _group_findings(findings())
334
425
 
335
426
 
336
- 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]:
337
438
  """Return the JSON-ready report and the total finding count across all tools."""
338
439
  summary: dict[str, object] = {}
339
440
  sections: dict[str, object] = {}
340
441
  grand_total = 0
442
+ ty_environment: dict[str, str] = {}
443
+
444
+ for tool in TOOL_NAMES:
445
+ if tool in skip:
446
+ continue
341
447
 
342
- for tool, run in (
343
- ("ruff", run_ruff),
344
- ("ruff-format", run_ruff_format),
345
- ("ty", run_ty),
346
- ("pymodularity", run_pymodularity),
347
- ):
348
448
  print(f"Running {tool} on {project_path} ...", file=sys.stderr)
349
- 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)
350
459
  total = sum(group.count for group in violations.values())
351
460
  print(f" {total} violations across {len(violations)} rules", file=sys.stderr)
352
461
 
@@ -354,5 +463,13 @@ def build_report(project_path: Path) -> tuple[dict[str, object], int]:
354
463
  sections[tool] = {rule: group.to_dict() for rule, group in violations.items()}
355
464
  grand_total += total
356
465
 
357
- 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)
358
475
  return report, grand_total
@@ -0,0 +1,54 @@
1
+ """Render a `build_report` result as one line per finding, for a terminal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from python_linters.lint_report import TOOL_NAMES
6
+
7
+
8
+ def format_text_report(report: dict[str, object]) -> str:
9
+ """One line per finding, sorted by file then line, with a totals line last.
10
+
11
+ `file:line:col tool/rule message`, matching how ruff and ty print their
12
+ own findings — unlike the JSON report, this is for a human reading a
13
+ terminal, not a script.
14
+ """
15
+ rows: list[tuple[str, int, int, str, str, str]] = []
16
+ for tool in TOOL_NAMES:
17
+ section = report.get(tool)
18
+ if not isinstance(section, dict):
19
+ continue
20
+ for rule, group in section.items():
21
+ rows.extend(
22
+ (
23
+ location["file"],
24
+ location["line"],
25
+ location["col"],
26
+ tool,
27
+ rule,
28
+ location["message"],
29
+ )
30
+ for location in group["locations"]
31
+ )
32
+
33
+ rows.sort(key=lambda row: (row[0], row[1]))
34
+
35
+ lines = [
36
+ f"{file}:{line}:{col} {tool}/{rule} {message}"
37
+ for file, line, col, tool, rule, message in rows
38
+ ]
39
+
40
+ summary_obj = report.get("summary", {})
41
+ summary: dict[str, dict[str, int]] = (
42
+ summary_obj if isinstance(summary_obj, dict) else {}
43
+ )
44
+ totals = " ".join(f"{tool}={data['total']}" for tool, data in summary.items())
45
+ grand_total = sum(data["total"] for data in summary.values())
46
+ totals_line = f"{totals} total={grand_total}" if totals else f"total={grand_total}"
47
+
48
+ baseline = report.get("baseline")
49
+ if isinstance(baseline, dict):
50
+ totals_line += f" baseline={baseline['ref']}"
51
+
52
+ lines.append(totals_line)
53
+
54
+ return "\n".join(lines)
@@ -0,0 +1,22 @@
1
+ """Resolved versions of this package and the tools it bundles.
2
+
3
+ Read from `importlib.metadata`, never from a subprocess call: the versions
4
+ are pinned in `pyproject.toml` and installed into the same environment this
5
+ package itself runs in, so the installed-distribution metadata already
6
+ names exactly what `sys.executable -m <tool>` will run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import version
12
+
13
+ TOOL_DISTRIBUTIONS = ("ruff", "ty", "pymodularity")
14
+
15
+
16
+ def package_version() -> str:
17
+ return version("python-linters")
18
+
19
+
20
+ def tool_versions() -> dict[str, str]:
21
+ """Bundled ruff/ty/pymodularity versions, keyed by distribution name."""
22
+ return {name: version(name) for name in TOOL_DISTRIBUTIONS}
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-linters
3
+ Version: 0.2.3
4
+ Summary: Run ruff, ty and pymodularity on a Python project and print one JSON report grouped by rule.
5
+ Author: Tilo Himmelsbach
6
+ License-Expression: MIT
7
+ Requires-Dist: ruff==0.17.5
8
+ Requires-Dist: ty==0.0.103
9
+ Requires-Dist: pymodularity==0.5.0
10
+ Requires-Python: >=3.11
11
+ Description-Content-Type: text/markdown
12
+
13
+ # python-linters
14
+
15
+ Run ruff, ty and pymodularity on a Python project and print one JSON report
16
+ grouped by rule, for agents and CI to read.
17
+
18
+ ## Install
19
+
20
+ As a dev dependency of the project you want to lint:
21
+
22
+ ```
23
+ uv add --dev python-linters
24
+ ```
25
+
26
+ ## Commands
27
+
28
+ - `uv run python-linters lint [path] [--out FILE] [--format json|text]
29
+ [--skip TOOL]... [--python PATH] [--extra-search-path DIR]...
30
+ [--baseline REF]` — runs
31
+ ruff, `ruff format --check`, ty and pymodularity (default path: `.`) and
32
+ prints the merged report to stdout, or writes it to `FILE` with `--out`.
33
+ - `--format text` prints one line per finding (`file:line:col
34
+ tool/rule message`, sorted by file then line) with a totals line
35
+ last, instead of the default JSON. `--out` writes whichever format
36
+ was chosen.
37
+ - `--skip TOOL` (repeatable; `ruff`, `ruff-format`, `ty`,
38
+ `pymodularity`) drops that tool: it does not run, gets no section and
39
+ no summary entry, and is listed under the report's top-level
40
+ `"skipped"`. Skipping all 4 is a usage error.
41
+ - `--python PATH` and `--extra-search-path DIR` (repeatable) are
42
+ forwarded verbatim to `ty check`'s own `--python`/`--extra-search-path`.
43
+ Needed whenever the project's imports do not resolve from a `.venv`
44
+ under the linted path — e.g. a ROS 2 package that resolves `rclpy`
45
+ and friends from a system interpreter and `/opt/ros/...`.
46
+ - ty looks for a `.venv` at *its own project root* — the directory
47
+ holding the nearest `ty.toml`, or a `pyproject.toml` with a
48
+ `[tool.ty]` section — not at the path you pass to `lint`. A shared
49
+ parent config can point ty's `.venv` search at the wrong directory for
50
+ a sub-package; the report's `"ty-environment"` key (below) shows what
51
+ ty actually resolved.
52
+ - ty ranks `$PYTHONPATH` entries ahead of first-party code. If
53
+ `PYTHONPATH` is set, a stale installed copy on it can shadow the
54
+ project tree being linted and silently report 0 findings; the report
55
+ names it under `"ty-environment"` when set.
56
+ - the report's top-level `"tools"` key names the bundled ruff/ty/
57
+ pymodularity versions; `"ty-environment"` names ty's resolved project
58
+ root and Python environment (absent if `ty` is skipped).
59
+ - `--baseline REF` (a branch, tag, SHA, `HEAD~N`, ...) reports only
60
+ findings that are new since that git ref: it checks the ref out into a
61
+ fresh `git worktree add --detach` scratch directory (never a reused
62
+ one — a stale copy there can carry findings from an earlier run),
63
+ lints the same sub-path there, and drops every current finding whose
64
+ `(file, rule, message)` triple already exists at the baseline. The
65
+ line number is excluded from that triple on purpose: a finding does
66
+ not become "new" just because a line was inserted above it. The
67
+ worktree is removed afterwards regardless of outcome. A ref that
68
+ doesn't resolve, or a `path` outside any git repo, exits 2 with the
69
+ reason on stderr. The report gets a top-level `"baseline": {"ref":
70
+ ..., "sha": ...}`; the summary and exit code count only the new
71
+ findings; `--format text` prints only the new findings plus the
72
+ baseline ref on the totals line. Since a fresh worktree has no
73
+ `.venv` of its own, the baseline's ty run is given `--python`
74
+ explicitly — the current run's resolved `ty-environment` Python
75
+ environment, unless you passed your own `--python` — and the report
76
+ names which one it used under `"baseline"`'s `"ty-python"` key.
77
+ `--skip` and `--extra-search-path` apply to both runs.
78
+ - **File-move artifact in the delta.** The baseline keys findings by
79
+ `(file, rule, message)`. A refactor that moves code from `old/x.py`
80
+ to `new/y.py` reports the entry at `old/x.py` as *gone* and the same
81
+ finding at `new/y.py` as *new* — net delta +1 even though the
82
+ codebase's lint posture is unchanged. Two responses: (a) accept the
83
+ +1 and document the move in the commit message; (b) commit and
84
+ refresh the baseline. Prefer (a) for one-off moves; (b) only when a
85
+ move is large enough to otherwise swamp the real new findings.
86
+ - `uv run python-linters --version` — prints this package's version and
87
+ the bundled ruff/ty/pymodularity versions on one line.
88
+ - `uv run python-linters reference ruff|ty` — prints this package's shipped
89
+ reference config for the given tool to stdout. The package never writes
90
+ it into your project — copying it into `ruff.toml`/`ty.toml` (or
91
+ `pyproject.toml`) stays with you.
92
+ - `uv run python-linters check-config ruff|ty <path/to/config>` — compares
93
+ your copy against the pinned tool's current rule set: rules the tool
94
+ knows that your config omits (untriaged), and rules your config names
95
+ that the tool no longer knows (stale). The report names the version of
96
+ the tool it checked against on its first line.
97
+
98
+ ## Exit codes
99
+
100
+ - `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr), a
101
+ usage error (e.g. `--skip` naming all 4 tools), or — with
102
+ `--baseline` — a ref that doesn't resolve or a path outside any git
103
+ repo.
104
+ - `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
105
+ failure.
@@ -0,0 +1,16 @@
1
+ python_linters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ python_linters/__main__.py,sha256=Xkx-N0S-OWOvqBBgDA0HgCwrW324UrwBEQbR8PUayJI,158
3
+ python_linters/baseline_report.py,sha256=IMDJ9kb8G1hVrN6FJi8tlS8qTD1CThyP5kLeWr6bbAI,7565
4
+ python_linters/cli_commands.py,sha256=ZaLvKjGd8b5aZX7myTmXQHuq98EMrRaLVs77ScQTlgQ,5454
5
+ python_linters/config_check.py,sha256=gFsI8KVBRkY_x7cREdRE3G51se_sfQAMAacTWR7pdxk,14687
6
+ python_linters/lint_report.py,sha256=usr2__SlHcniIhxHrOfm1xOzT631O5NURnDGND9yqsY,17650
7
+ python_linters/reference_files.py,sha256=pD0fGW-PJiV09LFIy9-hZKD7EVmHIUjkE3SfD-NeiI4,764
8
+ python_linters/references/ruff.reference.toml,sha256=Q6kKaeekpeiGcVQoqPDdFfWA-jUij66s8q33EZWWZQ0,29668
9
+ python_linters/references/ty.reference.toml,sha256=AKwhaBhs75QwbtrgqhuyoAYFTQX9Y7ns7TtwF8reQ4E,8244
10
+ python_linters/text_report.py,sha256=L6NRS9tXUoUhEA1ECd94Nzr5S4VKyU8NRCtSRtVV0ik,1814
11
+ python_linters/tool_errors.py,sha256=Fw3byFD0kk8QKJQg85weKB11_CRrnu2h_N2XQId6Abc,1572
12
+ python_linters/tool_versions.py,sha256=9XASpuj1L0CB_0css6yxBuBMWaMCIyq3r6YRRxTvZmE,726
13
+ python_linters-0.2.3.dist-info/WHEEL,sha256=3OmCxFA0ffBIsX4x0o8CdfSvZAOvO3mzEG8U_z5TvV4,80
14
+ python_linters-0.2.3.dist-info/entry_points.txt,sha256=2qemc7DiLCP7wVNw-8KENf-v5L62Jx40tHgVXC0s_FM,65
15
+ python_linters-0.2.3.dist-info/METADATA,sha256=wCV0j0SXk8vbUBonKKKLXFWIWb1ySYB2VKuRM3woYNw,5524
16
+ python_linters-0.2.3.dist-info/RECORD,,
@@ -1,44 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-linters
3
- Version: 0.2.2
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]` — runs ruff, `ruff
29
- format --check`, ty and pymodularity (default path: `.`) and prints the
30
- merged JSON report to stdout, or writes it to `FILE` with `--out`.
31
- - `uv run python-linters reference ruff|ty` — prints this package's shipped
32
- reference config for the given tool to stdout. The package never writes
33
- it into your project — copying it into `ruff.toml`/`ty.toml` (or
34
- `pyproject.toml`) stays with you.
35
- - `uv run python-linters check-config ruff|ty <path/to/config>` — compares
36
- your copy against the pinned tool's current rule set: rules the tool
37
- knows that your config omits (untriaged), and rules your config names
38
- that the tool no longer knows (stale).
39
-
40
- ## Exit codes
41
-
42
- - `lint`: 0 clean, 1 findings, 2 any tool failure (named on stderr).
43
- - `check-config`: 0 up to date, 1 untriaged or stale rules found, 2 tool
44
- failure.
@@ -1,13 +0,0 @@
1
- python_linters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- python_linters/__main__.py,sha256=Xkx-N0S-OWOvqBBgDA0HgCwrW324UrwBEQbR8PUayJI,158
3
- python_linters/cli_commands.py,sha256=tVneEJJd6bgBrzunto3UJoEJHupwc5wNjMfTGwFCmN0,3122
4
- python_linters/config_check.py,sha256=gFsI8KVBRkY_x7cREdRE3G51se_sfQAMAacTWR7pdxk,14687
5
- python_linters/lint_report.py,sha256=TgJEsKCDulBtC8Mx-40emK_ewgGJPkaJNGwgEl-EVBw,13066
6
- python_linters/reference_files.py,sha256=pD0fGW-PJiV09LFIy9-hZKD7EVmHIUjkE3SfD-NeiI4,764
7
- python_linters/references/ruff.reference.toml,sha256=Q6kKaeekpeiGcVQoqPDdFfWA-jUij66s8q33EZWWZQ0,29668
8
- python_linters/references/ty.reference.toml,sha256=AKwhaBhs75QwbtrgqhuyoAYFTQX9Y7ns7TtwF8reQ4E,8244
9
- python_linters/tool_errors.py,sha256=Fw3byFD0kk8QKJQg85weKB11_CRrnu2h_N2XQId6Abc,1572
10
- python_linters-0.2.2.dist-info/WHEEL,sha256=3OmCxFA0ffBIsX4x0o8CdfSvZAOvO3mzEG8U_z5TvV4,80
11
- python_linters-0.2.2.dist-info/entry_points.txt,sha256=2qemc7DiLCP7wVNw-8KENf-v5L62Jx40tHgVXC0s_FM,65
12
- python_linters-0.2.2.dist-info/METADATA,sha256=UEZArPlda0mJSwVW2ySXc91uCD8tKW9tsA8_nWjNvU8,1521
13
- python_linters-0.2.2.dist-info/RECORD,,