Skip to content

Commit 9907f33

Browse files
authored
Add rich to enrich output (#49)
* Enable Markdown support to hints * Add code highlighting * Colorize errors and warnings differently * Drop the obsolete `--msg-template` argument * Add a `--code-padding` argument to adjust code snippets * Add a `--summarize` argument to matches by test * Add progress bar
1 parent bff1db9 commit 9907f33

File tree

5 files changed

+148
-54
lines changed

5 files changed

+148
-54
lines changed

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ classifiers = [
2424
"Topic :: Software Development :: Testing",
2525
]
2626
requires-python = ">=3.8"
27-
dependencies = ["PyYAML"]
27+
dependencies = [
28+
"PyYAML",
29+
"rich",
30+
]
2831

2932
[project.optional-dependencies]
3033
test = [

relint/__main__.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import subprocess # nosec
44
import sys
55
import warnings
6-
from itertools import chain
6+
7+
from rich.progress import track
78

89
from relint.config import load_config
910
from relint.parse import lint_file, match_with_diff_changes, parse_diff, print_culprits
@@ -48,12 +49,18 @@ def parse_args(args=None):
4849
help="Do not output warnings. Could be useful when using relint in CI.",
4950
)
5051
parser.add_argument(
51-
"--msg-template",
52-
metavar="MSG_TEMPLATE",
53-
type=str,
54-
default="{filename}:{line_no} {test.name}\nHint: {test.hint}\n{match}",
55-
help="Template used to display messages. "
56-
r"Default: {filename}:{line_no} {test.name}\nHint: {test.hint}\n{match}",
52+
"--summarize",
53+
action="store_true",
54+
help="Summarize the output by grouping matches by test.",
55+
),
56+
parser.add_argument(
57+
"--code-padding",
58+
type=int,
59+
default=2,
60+
help=(
61+
"Lines of padding to show around the matching code snippet. Default: 2\n"
62+
"Set to -1 disable code snippet output."
63+
),
5764
)
5865
return parser.parse_args(args=args)
5966

@@ -73,7 +80,9 @@ def main(args=None):
7380

7481
tests = list(load_config(args.config, args.fail_warnings, args.ignore_warnings))
7582

76-
matches = chain.from_iterable(lint_file(path, tests) for path in paths)
83+
matches = []
84+
for path in track(paths, description="Linting files..."):
85+
matches.extend(lint_file(path, tests))
7786

7887
output = ""
7988
if args.diff:
@@ -87,7 +96,7 @@ def main(args=None):
8796
changed_content = parse_diff(output)
8897
matches = match_with_diff_changes(changed_content, matches)
8998

90-
exit_code = print_culprits(matches, args.msg_template)
99+
exit_code = print_culprits(matches, args)
91100
exit(exit_code)
92101

93102

relint/parse.py

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
from __future__ import annotations
22

3+
import collections
34
import re
45

6+
from rich import print as rprint
7+
from rich.console import Group
8+
from rich.markdown import Markdown
9+
from rich.panel import Panel
10+
from rich.syntax import Syntax
11+
512
GIT_DIFF_LINE_NUMBERS_PATTERN = re.compile(r"@ -\d+(,\d+)? \+(\d+)(,)?(\d+)? @")
613
GIT_DIFF_FILENAME_PATTERN = re.compile(r"(?:\n|^)diff --git a\/.* b\/(.*)(?:\n|$)")
714
GIT_DIFF_SPLIT_PATTERN = re.compile(r"(?:\n|^)diff --git a\/.* b\/.*(?:\n|$)")
@@ -82,37 +89,77 @@ def split_diff_content_by_filename(output: str) -> {str: str}:
8289
return content_by_filename
8390

8491

85-
def print_culprits(matches, msg_template):
92+
def print_culprits(matches, args):
8693
exit_code = 0
87-
_filename = ""
88-
lines = []
94+
messages = []
95+
match_groups = collections.defaultdict(list)
8996

9097
for filename, test, match, _ in matches:
9198
exit_code = test.error if exit_code == 0 else exit_code
9299

93-
if filename != _filename:
94-
_filename = filename
95-
lines = match.string.splitlines()
100+
start_line_no = match.string[: match.start()].count("\n") + 1
101+
end_line_no = match.string[: match.end()].count("\n") + 1
96102

97-
start_line_no = match.string[: match.start()].count("\n")
98-
end_line_no = match.string[: match.end()].count("\n")
99-
match_lines = (
100-
"{line_no}> {code_line}".format(
101-
line_no=no + start_line_no + 1,
102-
code_line=line.lstrip(),
103+
if args.summarize:
104+
match_groups[test].append(f"{filename}:{start_line_no}")
105+
else:
106+
hint = Panel(
107+
Markdown(test.hint, justify="left"),
108+
title="Hint:",
109+
title_align="left",
110+
padding=(0, 2),
103111
)
104-
for no, line in enumerate(lines[start_line_no : end_line_no + 1])
105-
)
106-
# special characters from shell are escaped
107-
msg_template = msg_template.replace("\\n", "\n")
108-
print(
109-
msg_template.format(
110-
filename=filename,
111-
line_no=start_line_no + 1,
112-
test=test,
113-
match=chr(10).join(match_lines),
112+
113+
if args.code_padding == -1:
114+
message = hint
115+
else:
116+
lexer = Syntax.guess_lexer(filename)
117+
syntax = Syntax(
118+
match.string,
119+
lexer=lexer,
120+
line_numbers=True,
121+
line_range=(
122+
start_line_no - args.code_padding,
123+
end_line_no + args.code_padding,
124+
),
125+
highlight_lines=range(start_line_no, end_line_no + 1),
126+
)
127+
message = Group(syntax, hint)
128+
129+
messages.append(
130+
Panel(
131+
message,
132+
title=f"{'Error' if test.error else 'Warning'}: {test.name}",
133+
title_align="left",
134+
subtitle=f"{filename}:{start_line_no}",
135+
subtitle_align="left",
136+
border_style="bold red" if test.error else "yellow",
137+
padding=(0, 2),
138+
)
114139
)
115-
)
140+
141+
if args.summarize:
142+
for test, filenames in match_groups.items():
143+
hint = Panel(
144+
Markdown(test.hint, justify="left"),
145+
title="Hint:",
146+
title_align="left",
147+
padding=(0, 2),
148+
)
149+
group = Group(Group(*filenames), hint)
150+
messages.append(
151+
Panel(
152+
group,
153+
title=f"{'Error' if test.error else 'Warning'}: {test.name}",
154+
title_align="left",
155+
subtitle=f"{len(filenames)} occurrence(s)",
156+
subtitle_align="left",
157+
border_style="bold red" if test.error else "yellow",
158+
padding=(0, 2),
159+
)
160+
)
161+
162+
rprint(*messages, sep="\n")
116163

117164
return exit_code
118165

tests/fixtures/.relint.yml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@
66

77
- name: No fixme (warning)
88
pattern: '[fF][iI][xX][mM][eE]'
9-
hint: Fix it right away!
9+
hint: |
10+
### This is a multiline hint
11+
Fix it right away!
12+
13+
You can use code blocks too, like Python:
14+
15+
```python
16+
print('hello world')
17+
```
18+
19+
Or JavaScript:
20+
21+
```javascript
22+
console.log('hello world')
23+
```
24+
25+
And even Git diffs:
26+
27+
```diff
28+
- print('hello world')
29+
+ console.log('hello world')
30+
```
31+
1032
filePattern: ^(?!.*test_).*\.(py|js)$
1133
error: true

tests/test_main.py

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,50 +37,65 @@ def test_main_execution_with_error(self, capsys, tmpdir, fixture_dir):
3737
with pytest.raises(SystemExit) as exc_info:
3838
main(["relint.py", "dummy.py"])
3939

40-
expected_message = "dummy.py:1 No fixme (warning)\nHint: Fix it right away!\n1> # FIXME do something\n"
41-
4240
out, _ = capsys.readouterr()
43-
assert expected_message == out
41+
assert "dummy.py:1" in out
42+
assert "No fixme (warning)" in out
43+
assert "Fix it right away!" in out
44+
assert "❱ 1 # FIXME do something" in out
4445
assert exc_info.value.code == 1
4546

46-
def test_main_execution_with_custom_template(self, capsys, tmpdir, fixture_dir):
47+
def test_raise_for_warnings(self, tmpdir, fixture_dir):
4748
with (fixture_dir / ".relint.yml").open() as fs:
4849
config = fs.read()
4950
tmpdir.join(".relint.yml").write(config)
5051
tmpdir.join("dummy.py").write("# TODO do something")
51-
5252
with tmpdir.as_cwd():
5353
with pytest.raises(SystemExit) as exc_info:
54-
template = r"😵{filename}:{line_no} | {test.name} \n {match}"
55-
main(["relint.py", "dummy.py", "--msg-template", template])
54+
main(["relint.py", "dummy.py", "-W"])
5655

57-
expected_message = "😵dummy.py:1 | No ToDo \n" " 1> # TODO do something\n"
56+
assert exc_info.value.code == 1
57+
58+
def test_ignore_warnings(self, tmpdir, fixture_dir):
59+
with (fixture_dir / ".relint.yml").open() as fs:
60+
config = fs.read()
61+
tmpdir.join(".relint.yml").write(config)
62+
tmpdir.join("dummy.py").write("# TODO do something")
63+
with tmpdir.as_cwd():
64+
with pytest.raises(SystemExit) as exc_info:
65+
main(["relint.py", "dummy.py", "--ignore-warnings"])
5866

59-
out, _ = capsys.readouterr()
60-
assert expected_message == out
6167
assert exc_info.value.code == 0
6268

63-
def test_raise_for_warnings(self, tmpdir, fixture_dir):
69+
def test_summarize(self, tmpdir, fixture_dir, capsys):
6470
with (fixture_dir / ".relint.yml").open() as fs:
6571
config = fs.read()
6672
tmpdir.join(".relint.yml").write(config)
67-
tmpdir.join("dummy.py").write("# TODO do something")
73+
tmpdir.join("dummy.py").write("# FIXME do something")
6874
with tmpdir.as_cwd():
6975
with pytest.raises(SystemExit) as exc_info:
70-
main(["relint.py", "dummy.py", "-W"])
76+
main(["relint.py", "dummy.py", "--summarize"])
7177

78+
out, _ = capsys.readouterr()
79+
assert "dummy.py:1" in out
80+
assert "No fixme (warning)" in out
81+
assert "Fix it right away!" in out
82+
assert "1 occurrence(s)" in out
7283
assert exc_info.value.code == 1
7384

74-
def test_ignore_warnings(self, tmpdir, fixture_dir):
85+
def test_code_padding_disabled(self, tmpdir, fixture_dir, capsys):
7586
with (fixture_dir / ".relint.yml").open() as fs:
7687
config = fs.read()
7788
tmpdir.join(".relint.yml").write(config)
78-
tmpdir.join("dummy.py").write("# TODO do something")
89+
tmpdir.join("dummy.py").write("# FIXME do something")
7990
with tmpdir.as_cwd():
8091
with pytest.raises(SystemExit) as exc_info:
81-
main(["relint.py", "dummy.py", "--ignore-warnings"])
92+
main(["relint.py", "dummy.py", "--code-padding=-1"])
8293

83-
assert exc_info.value.code == 0
94+
out, _ = capsys.readouterr()
95+
assert "dummy.py:1" in out
96+
assert "No fixme (warning)" in out
97+
assert "Fix it right away!" in out
98+
assert exc_info.value.code == 1
8499

85100
def test_main_execution_with_diff(self, capsys, mocker, tmpdir, fixture_dir):
86101
with (fixture_dir / ".relint.yml").open() as fs:
@@ -99,8 +114,6 @@ def test_main_execution_with_diff(self, capsys, mocker, tmpdir, fixture_dir):
99114
with pytest.raises(SystemExit) as exc_info:
100115
main(["relint.py", "dummy.py", "--diff"])
101116

102-
expected_message = "Hint: Get it done right away!"
103-
104117
out, _ = capsys.readouterr()
105-
assert expected_message in out
118+
assert "Get it done right away!" in out
106119
assert exc_info.value.code == 0

0 commit comments

Comments
 (0)