Skip to content

Commit 4338c55

Browse files
authored
feat(sp-ruff-checks): plain and auto output format (#786)
* feat(sp-ruff-checks): plain and auto output format Auto-detects some common agent harnesses. Will run in plain mode if rich is not installed. Assisted-by: Copilot:GPT-5.4-mini Assisted-by: OpenCode:Kimi-K2.6 Signed-off-by: Henry Schreiner <henryfs@princeton.edu> * chore: clean up a littl Signed-off-by: Henry Schreiner <henryfs@princeton.edu> --------- Signed-off-by: Henry Schreiner <henryfs@princeton.edu>
1 parent 8819e08 commit 4338c55

3 files changed

Lines changed: 232 additions & 50 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ messages_control.disable = [
165165
"redefined-outer-name",
166166
"no-member", # better handled by mypy, etc.
167167
"arguments-differ", # better handled by mypy, etc.
168+
"import-outside-toplevel", # in Ruff
168169
]
169170

170171

@@ -196,6 +197,7 @@ ignore = [
196197
[tool.ruff.lint.per-file-ignores]
197198
"src/sp_repo_review/_compat/**.py" = ["TID251"]
198199
"src/sp_repo_review/checks/*.py" = ["ERA001"]
200+
"src/sp_repo_review/ruff_checks/__main__.py" = ["PLC0415", "T20"]
199201
"tests/**" = ["ANN", "INP001", "S607"]
200202
"helpers/**" = ["INP001", "FIX004"]
201203
"helpers/extensions.py" = ["ANN"]

src/sp_repo_review/ruff_checks/__main__.py

Lines changed: 188 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,25 @@
22
"argparse",
33
"collections",
44
"collections.abc",
5+
"os",
56
"pathlib",
6-
"rich",
7-
"rich.columns",
8-
"rich.panel",
97
"sp_repo_review._compat",
108
"sp_repo_review.checks",
119
"sp_repo_review.checks.ruff",
1210
"sys",
11+
"typing",
1312
]
1413

1514
import argparse
15+
import importlib
1616
import importlib.resources
1717
import json
18+
import os
1819
import sys
1920
from collections.abc import Iterator, Mapping
21+
from importlib.util import find_spec
2022
from pathlib import Path
2123

22-
from rich import print
23-
from rich.columns import Columns
24-
from rich.panel import Panel
25-
2624
from sp_repo_review._compat import tomllib
2725
from sp_repo_review.checks.ruff import get_rule_selection, ruff
2826

@@ -42,53 +40,200 @@
4240
with RESOURCE_DIR.joinpath("ignore.json").open(encoding="utf-8") as f:
4341
IGNORE_INFO = json.load(f)
4442

43+
# Tool-specific agent variables
44+
# Based on https://github.com/agentsmd/agents.md/issues/136
45+
_AGENT_VARS = [
46+
"AGENT", # Pi, Goose, Amp
47+
"CLAUDECODE",
48+
"CURSOR_AGENT",
49+
"CLINE_ACTIVE",
50+
"GEMINI_CLI",
51+
"CODEX_SANDBOX",
52+
"AUGMENT_AGENT",
53+
"TRAE_AI_SHELL_ID",
54+
"OPENCODE_CLIENT",
55+
]
56+
57+
58+
def _is_agent_environment() -> bool:
59+
"""Check if running from an AI coding agent using env vars."""
60+
return any(os.environ.get(var) for var in _AGENT_VARS)
61+
62+
63+
def _resolve_format(format_arg: str) -> str:
64+
"""Resolve 'auto' format to either 'rich' or 'plain'."""
65+
if format_arg != "auto":
66+
return format_arg
67+
68+
if _is_agent_environment():
69+
return "plain"
70+
71+
return "rich" if _has_rich() else "plain"
72+
73+
74+
def _has_rich() -> bool:
75+
return find_spec("rich") is not None
76+
77+
78+
def _print_each_plain(items: Mapping[str, str], indent: int = 2) -> Iterator[str]:
79+
"""Generate plain text formatted rule lines."""
80+
size = max(len(k) for k in items) if items else 0
81+
for k, v in items.items():
82+
yield f'{" " * indent}"{k}",{" " * (size - len(k))} # {v}'
83+
4584

46-
def print_each(items: Mapping[str, str]) -> Iterator[str]:
85+
def _print_each_rich(items: Mapping[str, str]) -> Iterator[str]:
86+
"""Generate rich formatted rule lines."""
4787
size = max(len(k) for k in items) if items else 0
4888
for k, v in items.items():
4989
kk = f'[green]"{k}"[/green],'
5090
yield f" {kk:{size + 18}} [dim]# {v}[/dim]"
5191

5292

53-
def process_dir(path: Path) -> None:
93+
def _output_error(fmt: str, message: str) -> None:
94+
"""Output error message in appropriate format."""
95+
if fmt == "rich":
96+
import rich
97+
98+
rich.print(message, file=sys.stderr)
99+
else:
100+
print(message, file=sys.stderr)
101+
102+
103+
def _print_output_rich(
104+
selected_items: dict[str, str],
105+
libs_items: dict[str, str],
106+
spec_items: dict[str, str],
107+
unselected_items: dict[str, str],
108+
) -> None:
109+
"""Print rich formatted output."""
110+
import rich.columns
111+
import rich.panel
112+
113+
panel_sel = rich.panel.Panel(
114+
"\n".join(_print_each_rich(selected_items)),
115+
title="Selected",
116+
border_style="green",
117+
)
118+
panel_lib = rich.panel.Panel(
119+
"\n".join(_print_each_rich(libs_items)),
120+
title="Library specific",
121+
border_style="yellow",
122+
)
123+
panel_spec = rich.panel.Panel(
124+
"\n".join(_print_each_rich(spec_items)),
125+
title="Specialized",
126+
border_style="yellow",
127+
)
128+
uns = "\n".join(_print_each_rich(unselected_items))
129+
130+
rich.print(rich.columns.Columns([panel_sel, panel_lib, panel_spec]))
131+
if uns:
132+
rich.print("[red]Unselected [dim](copy and paste ready)")
133+
rich.print(uns)
134+
135+
136+
def _print_output_plain(
137+
selected_items: dict[str, str],
138+
libs_items: dict[str, str],
139+
spec_items: dict[str, str],
140+
unselected_items: dict[str, str],
141+
) -> None:
142+
"""Print plain formatted output."""
143+
print("Selected:")
144+
for item in _print_each_plain(selected_items):
145+
print(item)
146+
147+
if libs_items:
148+
print("\nLibrary specific:")
149+
for item in _print_each_plain(libs_items):
150+
print(item)
151+
152+
if spec_items:
153+
print("\nSpecialized:")
154+
for item in _print_each_plain(spec_items):
155+
print(item)
156+
157+
if unselected_items:
158+
print("\nUnselected (copy and paste ready):")
159+
for item in _print_each_plain(unselected_items):
160+
print(item)
161+
162+
163+
def _handle_all_selected(fmt: str, ruff_config: dict[str, object]) -> None:
164+
"""Handle the case when ALL rules are selected."""
165+
ignored = get_rule_selection(ruff_config, "ignore")
166+
missed = [
167+
r
168+
for r in IGNORE_INFO
169+
if not any(
170+
x.startswith((r.get("rule", "."), r.get("family", ".")))
171+
for x in (ignored or [])
172+
)
173+
]
174+
175+
msg = '[green]"ALL"[/green] selected.' if fmt == "rich" else '"ALL" selected.'
176+
if fmt == "rich":
177+
import rich
178+
179+
rich.print(msg)
180+
else:
181+
print(msg)
182+
183+
ignores = {v.get("rule", v.get("family", "")): v["reason"] for v in missed}
184+
if ignores:
185+
msg_header = "Some things that sometimes need ignoring:"
186+
if fmt == "rich":
187+
import rich
188+
189+
rich.print(msg_header)
190+
for item in _print_each_rich(ignores):
191+
rich.print(item)
192+
else:
193+
print(msg_header)
194+
for item in _print_each_plain(ignores):
195+
print(item)
196+
197+
198+
def process_dir(path: Path, format: str = "auto") -> None:
199+
"""Process a directory and display ruff rules configuration.
200+
201+
Args:
202+
path: Directory to process
203+
format: Output format - 'auto', 'rich', or 'plain'
204+
"""
205+
fmt = _resolve_format(format)
206+
54207
try:
55208
with path.joinpath("pyproject.toml").open("rb") as f:
56209
pyproject = tomllib.load(f)
57210
except FileNotFoundError:
58211
pyproject = {}
59212

60213
ruff_config = ruff(pyproject=pyproject, root=path)
214+
if fmt == "rich" and not _has_rich():
215+
_output_error(
216+
"plain", "Error: --format rich requested, but rich is not installed"
217+
)
218+
raise SystemExit(3)
219+
61220
if ruff_config is None:
62-
print(
63-
"[red]Could not find a ruff config [dim](.ruff.toml, ruff.toml, or pyproject.toml)",
64-
file=sys.stderr,
221+
msg = (
222+
"[red]Could not find a ruff config [dim](.ruff.toml, ruff.toml, or pyproject.toml)"
223+
if fmt == "rich"
224+
else "Error: Could not find a ruff config (.ruff.toml, ruff.toml, or pyproject.toml)"
65225
)
226+
_output_error(fmt, msg)
66227
raise SystemExit(1)
228+
67229
selected = get_rule_selection(ruff_config)
68230
if not selected:
69-
print(
70-
"[red]No rules selected",
71-
file=sys.stderr,
72-
)
231+
msg = "[red]No rules selected" if fmt == "rich" else "Error: No rules selected"
232+
_output_error(fmt, msg)
73233
raise SystemExit(2)
74234

75235
if "ALL" in selected:
76-
ignored = get_rule_selection(ruff_config, "ignore")
77-
missed = [
78-
r
79-
for r in IGNORE_INFO
80-
if not any(
81-
x.startswith((r.get("rule", "."), r.get("family", ".")))
82-
for x in ignored
83-
)
84-
]
85-
86-
print('[green]"ALL"[/green] selected.')
87-
ignores = {v.get("rule", v.get("family", "")): v["reason"] for v in missed}
88-
if ignores:
89-
print("Some things that sometimes need ignoring:")
90-
for item in print_each(ignores):
91-
print(item)
236+
_handle_all_selected(fmt, ruff_config)
92237
return
93238

94239
selected_items = {k: v for k, v in LINT_INFO.items() if k in selected}
@@ -99,23 +244,10 @@ def process_dir(path: Path) -> None:
99244
libs_items = {k: v for k, v in all_uns_items.items() if k in LIBS}
100245
spec_items = {k: v for k, v in all_uns_items.items() if k in SPECIALTY}
101246

102-
panel_sel = Panel(
103-
"\n".join(print_each(selected_items)), title="Selected", border_style="green"
104-
)
105-
panel_lib = Panel(
106-
"\n".join(print_each(libs_items)),
107-
title="Library specific",
108-
border_style="yellow",
109-
)
110-
panel_spec = Panel(
111-
"\n".join(print_each(spec_items)), title="Specialized", border_style="yellow"
112-
)
113-
uns = "\n".join(print_each(unselected_items))
114-
115-
print(Columns([panel_sel, panel_lib, panel_spec]))
116-
if uns:
117-
print("[red]Unselected [dim](copy and paste ready)")
118-
print(uns)
247+
if fmt == "rich":
248+
_print_output_rich(selected_items, libs_items, spec_items, unselected_items)
249+
else:
250+
_print_output_plain(selected_items, libs_items, spec_items, unselected_items)
119251

120252

121253
def main() -> None:
@@ -127,9 +259,15 @@ def main() -> None:
127259
default=Path.cwd(),
128260
help="Directory to process (default: current working directory)",
129261
)
262+
parser.add_argument(
263+
"--format",
264+
choices=["auto", "rich", "plain"],
265+
default="auto",
266+
help="Output format (default: auto)",
267+
)
130268
args = parser.parse_args()
131269

132-
process_dir(args.path)
270+
process_dir(args.path, format=args.format)
133271

134272

135273
if __name__ == "__main__":

tests/test_ruff_checks_cli.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import sys
2+
from importlib.util import find_spec as _find_spec
3+
4+
from sp_repo_review.ruff_checks import __main__ as ruff_checks
5+
6+
7+
def test_auto_and_plain_do_not_require_rich(monkeypatch, tmp_path, capsys):
8+
def no_rich_find_spec(name, package=None):
9+
if name == "rich" or name.startswith("rich."):
10+
return None
11+
return _find_spec(name, package=package)
12+
13+
monkeypatch.setattr(ruff_checks, "ruff", lambda *_a, **_k: {"tool": "ruff"})
14+
monkeypatch.setattr(ruff_checks, "get_rule_selection", lambda *_a, **_k: {"A"})
15+
monkeypatch.setattr(ruff_checks, "LINT_INFO", {"A": "Rule A"})
16+
monkeypatch.setattr(ruff_checks, "LIBS", frozenset())
17+
monkeypatch.setattr(ruff_checks, "SPECIALTY", frozenset())
18+
monkeypatch.setattr(ruff_checks, "_is_agent_environment", lambda: False)
19+
monkeypatch.setattr(ruff_checks, "find_spec", no_rich_find_spec)
20+
21+
for mod in list(sys.modules):
22+
if mod == "rich" or mod.startswith("rich."):
23+
monkeypatch.delitem(sys.modules, mod, raising=False)
24+
25+
for fmt in ("plain", "auto"):
26+
ruff_checks.process_dir(tmp_path, format=fmt)
27+
captured = capsys.readouterr()
28+
assert "Selected:" in captured.out
29+
assert captured.err == ""
30+
31+
32+
def test_plain_format_has_quotes_and_comma(monkeypatch, tmp_path, capsys):
33+
"""Regression test: plain format should quote rules for copy-paste."""
34+
monkeypatch.setattr(ruff_checks, "ruff", lambda *_a, **_k: {"tool": "ruff"})
35+
monkeypatch.setattr(ruff_checks, "get_rule_selection", lambda *_a, **_k: {"A"})
36+
monkeypatch.setattr(ruff_checks, "LINT_INFO", {"A": "Rule A"})
37+
monkeypatch.setattr(ruff_checks, "LIBS", frozenset())
38+
monkeypatch.setattr(ruff_checks, "SPECIALTY", frozenset())
39+
40+
ruff_checks.process_dir(tmp_path, format="plain")
41+
captured = capsys.readouterr()
42+
assert '"A",' in captured.out

0 commit comments

Comments
 (0)