Skip to content

Commit b00a6b9

Browse files
committed
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>
1 parent 8819e08 commit b00a6b9

3 files changed

Lines changed: 235 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: 191 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

@@ -43,52 +41,202 @@
4341
IGNORE_INFO = json.load(f)
4442

4543

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

5295

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

60216
ruff_config = ruff(pyproject=pyproject, root=path)
217+
if fmt == "rich" and not _has_rich():
218+
_output_error(
219+
"plain", "Error: --format rich requested, but rich is not installed"
220+
)
221+
raise SystemExit(3)
222+
61223
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,
224+
msg = (
225+
"[red]Could not find a ruff config [dim](.ruff.toml, ruff.toml, or pyproject.toml)"
226+
if fmt == "rich"
227+
else "Error: Could not find a ruff config (.ruff.toml, ruff.toml, or pyproject.toml)"
65228
)
229+
_output_error(fmt, msg)
66230
raise SystemExit(1)
231+
67232
selected = get_rule_selection(ruff_config)
68233
if not selected:
69-
print(
70-
"[red]No rules selected",
71-
file=sys.stderr,
72-
)
234+
msg = "[red]No rules selected" if fmt == "rich" else "Error: No rules selected"
235+
_output_error(fmt, msg)
73236
raise SystemExit(2)
74237

75238
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)
239+
_handle_all_selected(fmt, ruff_config)
92240
return
93241

94242
selected_items = {k: v for k, v in LINT_INFO.items() if k in selected}
@@ -99,23 +247,10 @@ def process_dir(path: Path) -> None:
99247
libs_items = {k: v for k, v in all_uns_items.items() if k in LIBS}
100248
spec_items = {k: v for k, v in all_uns_items.items() if k in SPECIALTY}
101249

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)
250+
if fmt == "rich":
251+
_print_output_rich(selected_items, libs_items, spec_items, unselected_items)
252+
else:
253+
_print_output_plain(selected_items, libs_items, spec_items, unselected_items)
119254

120255

121256
def main() -> None:
@@ -127,9 +262,15 @@ def main() -> None:
127262
default=Path.cwd(),
128263
help="Directory to process (default: current working directory)",
129264
)
265+
parser.add_argument(
266+
"--format",
267+
choices=["auto", "rich", "plain"],
268+
default="auto",
269+
help="Output format (default: auto)",
270+
)
130271
args = parser.parse_args()
131272

132-
process_dir(args.path)
273+
process_dir(args.path, format=args.format)
133274

134275

135276
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)