Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ classifiers = [
"Topic :: Software Development :: Testing",
]
requires-python = ">=3.8"
dependencies = ["PyYAML"]
dependencies = [
"PyYAML",
"rich",
]

[project.optional-dependencies]
test = [
Expand Down
27 changes: 18 additions & 9 deletions relint/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import subprocess # nosec
import sys
import warnings
from itertools import chain

from rich.progress import track

from relint.config import load_config
from relint.parse import lint_file, match_with_diff_changes, parse_diff, print_culprits
Expand Down Expand Up @@ -48,12 +49,18 @@ def parse_args(args=None):
help="Do not output warnings. Could be useful when using relint in CI.",
)
parser.add_argument(
"--msg-template",
metavar="MSG_TEMPLATE",
type=str,
default="{filename}:{line_no} {test.name}\nHint: {test.hint}\n{match}",
help="Template used to display messages. "
r"Default: {filename}:{line_no} {test.name}\nHint: {test.hint}\n{match}",
"--summarize",
action="store_true",
help="Summarize the output by grouping matches by test.",
),
parser.add_argument(
"--code-padding",
type=int,
default=2,
help=(
"Lines of padding to show around the matching code snippet. Default: 2\n"
"Set to -1 disable code snippet output."
),
)
return parser.parse_args(args=args)

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

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

matches = chain.from_iterable(lint_file(path, tests) for path in paths)
matches = []
for path in track(paths, description="Linting files..."):
matches.extend(lint_file(path, tests))

output = ""
if args.diff:
Expand All @@ -87,7 +96,7 @@ def main(args=None):
changed_content = parse_diff(output)
matches = match_with_diff_changes(changed_content, matches)

exit_code = print_culprits(matches, args.msg_template)
exit_code = print_culprits(matches, args)
exit(exit_code)


Expand Down
93 changes: 70 additions & 23 deletions relint/parse.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
from __future__ import annotations

import collections
import re

from rich import print as rprint
from rich.console import Group
from rich.markdown import Markdown
from rich.panel import Panel
from rich.syntax import Syntax

GIT_DIFF_LINE_NUMBERS_PATTERN = re.compile(r"@ -\d+(,\d+)? \+(\d+)(,)?(\d+)? @")
GIT_DIFF_FILENAME_PATTERN = re.compile(r"(?:\n|^)diff --git a\/.* b\/(.*)(?:\n|$)")
GIT_DIFF_SPLIT_PATTERN = re.compile(r"(?:\n|^)diff --git a\/.* b\/.*(?:\n|$)")
Expand Down Expand Up @@ -82,37 +89,77 @@ def split_diff_content_by_filename(output: str) -> {str: str}:
return content_by_filename


def print_culprits(matches, msg_template):
def print_culprits(matches, args):
exit_code = 0
_filename = ""
lines = []
messages = []
match_groups = collections.defaultdict(list)

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

if filename != _filename:
_filename = filename
lines = match.string.splitlines()
start_line_no = match.string[: match.start()].count("\n") + 1
end_line_no = match.string[: match.end()].count("\n") + 1

start_line_no = match.string[: match.start()].count("\n")
end_line_no = match.string[: match.end()].count("\n")
match_lines = (
"{line_no}> {code_line}".format(
line_no=no + start_line_no + 1,
code_line=line.lstrip(),
if args.summarize:
match_groups[test].append(f"{filename}:{start_line_no}")
else:
hint = Panel(
Markdown(test.hint, justify="left"),
title="Hint:",
title_align="left",
padding=(0, 2),
)
for no, line in enumerate(lines[start_line_no : end_line_no + 1])
)
# special characters from shell are escaped
msg_template = msg_template.replace("\\n", "\n")
print(
msg_template.format(
filename=filename,
line_no=start_line_no + 1,
test=test,
match=chr(10).join(match_lines),

if args.code_padding == -1:
message = hint
else:
lexer = Syntax.guess_lexer(filename)
syntax = Syntax(
match.string,
lexer=lexer,
line_numbers=True,
line_range=(
start_line_no - args.code_padding,
end_line_no + args.code_padding,
),
highlight_lines=range(start_line_no, end_line_no + 1),
)
message = Group(syntax, hint)

messages.append(
Panel(
message,
title=f"{'Error' if test.error else 'Warning'}: {test.name}",
title_align="left",
subtitle=f"{filename}:{start_line_no}",
subtitle_align="left",
border_style="bold red" if test.error else "yellow",
padding=(0, 2),
)
)
)

if args.summarize:
for test, filenames in match_groups.items():
hint = Panel(
Markdown(test.hint, justify="left"),
title="Hint:",
title_align="left",
padding=(0, 2),
)
group = Group(Group(*filenames), hint)
messages.append(
Panel(
group,
title=f"{'Error' if test.error else 'Warning'}: {test.name}",
title_align="left",
subtitle=f"{len(filenames)} occurrence(s)",
subtitle_align="left",
border_style="bold red" if test.error else "yellow",
padding=(0, 2),
)
)

rprint(*messages, sep="\n")

return exit_code

Expand Down
24 changes: 23 additions & 1 deletion tests/fixtures/.relint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@

- name: No fixme (warning)
pattern: '[fF][iI][xX][mM][eE]'
hint: Fix it right away!
hint: |
### This is a multiline hint
Fix it right away!

You can use code blocks too, like Python:

```python
print('hello world')
```

Or JavaScript:

```javascript
console.log('hello world')
```

And even Git diffs:

```diff
- print('hello world')
+ console.log('hello world')
```

filePattern: ^(?!.*test_).*\.(py|js)$
error: true
53 changes: 33 additions & 20 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,50 +37,65 @@ def test_main_execution_with_error(self, capsys, tmpdir, fixture_dir):
with pytest.raises(SystemExit) as exc_info:
main(["relint.py", "dummy.py"])

expected_message = "dummy.py:1 No fixme (warning)\nHint: Fix it right away!\n1> # FIXME do something\n"

out, _ = capsys.readouterr()
assert expected_message == out
assert "dummy.py:1" in out
assert "No fixme (warning)" in out
assert "Fix it right away!" in out
assert "❱ 1 # FIXME do something" in out
assert exc_info.value.code == 1

def test_main_execution_with_custom_template(self, capsys, tmpdir, fixture_dir):
def test_raise_for_warnings(self, tmpdir, fixture_dir):
with (fixture_dir / ".relint.yml").open() as fs:
config = fs.read()
tmpdir.join(".relint.yml").write(config)
tmpdir.join("dummy.py").write("# TODO do something")

with tmpdir.as_cwd():
with pytest.raises(SystemExit) as exc_info:
template = r"😵{filename}:{line_no} | {test.name} \n {match}"
main(["relint.py", "dummy.py", "--msg-template", template])
main(["relint.py", "dummy.py", "-W"])

expected_message = "😵dummy.py:1 | No ToDo \n" " 1> # TODO do something\n"
assert exc_info.value.code == 1

def test_ignore_warnings(self, tmpdir, fixture_dir):
with (fixture_dir / ".relint.yml").open() as fs:
config = fs.read()
tmpdir.join(".relint.yml").write(config)
tmpdir.join("dummy.py").write("# TODO do something")
with tmpdir.as_cwd():
with pytest.raises(SystemExit) as exc_info:
main(["relint.py", "dummy.py", "--ignore-warnings"])

out, _ = capsys.readouterr()
assert expected_message == out
assert exc_info.value.code == 0

def test_raise_for_warnings(self, tmpdir, fixture_dir):
def test_summarize(self, tmpdir, fixture_dir, capsys):
with (fixture_dir / ".relint.yml").open() as fs:
config = fs.read()
tmpdir.join(".relint.yml").write(config)
tmpdir.join("dummy.py").write("# TODO do something")
tmpdir.join("dummy.py").write("# FIXME do something")
with tmpdir.as_cwd():
with pytest.raises(SystemExit) as exc_info:
main(["relint.py", "dummy.py", "-W"])
main(["relint.py", "dummy.py", "--summarize"])

out, _ = capsys.readouterr()
assert "dummy.py:1" in out
assert "No fixme (warning)" in out
assert "Fix it right away!" in out
assert "1 occurrence(s)" in out
assert exc_info.value.code == 1

def test_ignore_warnings(self, tmpdir, fixture_dir):
def test_code_padding_disabled(self, tmpdir, fixture_dir, capsys):
with (fixture_dir / ".relint.yml").open() as fs:
config = fs.read()
tmpdir.join(".relint.yml").write(config)
tmpdir.join("dummy.py").write("# TODO do something")
tmpdir.join("dummy.py").write("# FIXME do something")
with tmpdir.as_cwd():
with pytest.raises(SystemExit) as exc_info:
main(["relint.py", "dummy.py", "--ignore-warnings"])
main(["relint.py", "dummy.py", "--code-padding=-1"])

assert exc_info.value.code == 0
out, _ = capsys.readouterr()
assert "dummy.py:1" in out
assert "No fixme (warning)" in out
assert "Fix it right away!" in out
assert exc_info.value.code == 1

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

expected_message = "Hint: Get it done right away!"

out, _ = capsys.readouterr()
assert expected_message in out
assert "Get it done right away!" in out
assert exc_info.value.code == 0