Skip to content

Commit 69e8a89

Browse files
anapaulagomescodingjoe
authored andcommitted
Add support to limit checks to git diff (#1)
1 parent 3b9446c commit 69e8a89

File tree

5 files changed

+341
-21
lines changed

5 files changed

+341
-21
lines changed

README.rst

+24
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,27 @@ The following command will lint all files in the current directory:
4646
The default configuration file name is `.relint.yaml` within your working
4747
directory, but you can provide any YAML or JSON file.
4848

49+
If you prefer linting changed files (cached on git) you can use the option
50+
`--diff [-d]`:
51+
52+
.. code-block:: bash
53+
54+
git diff | relint my_file.py --diff
55+
56+
This option is useful for pre-commit purposes. Here an example of how to use it
57+
with `pre-commit`_ framework:
58+
59+
.. code-block:: YAML
60+
61+
- repo: local
62+
hooks:
63+
- id: relint
64+
name: relint
65+
entry: bin/relint-pre-commit.sh
66+
language: system
67+
68+
You can find an example of `relint-pre-commit.sh`_ in this repository.
69+
4970
Samples
5071
-------
5172

@@ -76,3 +97,6 @@ Samples
7697
hint: "Please write to self.stdout or self.stderr in favor of using a logger."
7798
filename:
7899
- "*/management/commands/*.py"
100+
101+
.. _`pre-commit`: https://pre-commit.com/
102+
.. _`relint-pre-commit.sh`: relint-pre-commit.sh

relint-pre-commit.sh

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env sh
2+
3+
set -eo pipefail
4+
git diff --staged | relint --diff ${@:1}

relint.py

+121-20
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,24 @@
22
import fnmatch
33
import glob
44
import re
5+
import sys
56
from collections import namedtuple
67
from itertools import chain
78

89
import yaml
910

1011

12+
GIT_DIFF_LINE_NUMBERS_PATTERN = re.compile(
13+
r"@ -\d+(,\d+)? \+(\d+)(,)?(\d+)? @")
14+
GIT_DIFF_FILENAME_PATTERN = re.compile(
15+
r"(?:\n|^)diff --git a\/.* b\/(.*)(?:\n|$)")
16+
GIT_DIFF_SPLIT_PATTERN = re.compile(
17+
r"(?:\n|^)diff --git a\/.* b\/.*(?:\n|$)")
18+
19+
20+
Test = namedtuple('Test', ('name', 'pattern', 'hint', 'filename', 'error'))
21+
22+
1123
def parse_args():
1224
parser = argparse.ArgumentParser()
1325
parser.add_argument(
@@ -18,19 +30,22 @@ def parse_args():
1830
help='Path to one or multiple files to be checked.'
1931
)
2032
parser.add_argument(
21-
'--config',
2233
'-c',
34+
'--config',
2335
metavar='CONFIG_FILE',
2436
type=str,
2537
default='.relint.yml',
2638
help='Path to config file, default: .relint.yml'
2739
)
40+
parser.add_argument(
41+
'-d',
42+
'--diff',
43+
action='store_true',
44+
help='Analyze content from git diff.'
45+
)
2846
return parser.parse_args()
2947

3048

31-
Test = namedtuple('Test', ('name', 'pattern', 'hint', 'filename', 'error'))
32-
33-
3449
def load_config(path):
3550
with open(path) as fs:
3651
for test in yaml.load(fs):
@@ -56,31 +71,77 @@ def lint_file(filename, tests):
5671
for test in tests:
5772
if any(fnmatch.fnmatch(filename, fp) for fp in test.filename):
5873
for match in test.pattern.finditer(content):
59-
yield filename, test, match
74+
line_number = match.string[:match.start()].count('\n') + 1
75+
yield filename, test, match, line_number
6076

6177

62-
def main():
63-
args = parse_args()
64-
paths = {
65-
path
66-
for file in args.files
67-
for path in glob.iglob(file, recursive=True)
68-
}
78+
def parse_line_numbers(output):
79+
"""
80+
Extract line numbers from ``git diff`` output.
6981
70-
tests = list(load_config(args.config))
82+
Git shows which lines were changed indicating a start line
83+
and how many lines were changed from that. If only one
84+
line was changed, the output will display only the start line,
85+
like this:
86+
``@@ -54 +54 @@ import glob``
87+
If more lines were changed from that point, it will show
88+
how many after a comma:
89+
``@@ -4,2 +4,2 @@ import glob``
90+
It means that line number 4 and the following 2 lines were changed
91+
(5 and 6).
7192
72-
matches = chain.from_iterable(
73-
lint_file(path, tests)
74-
for path in paths
75-
)
93+
Args:
94+
output (int): ``git diff`` output.
95+
96+
Returns:
97+
list: All changed line numbers.
98+
"""
99+
line_numbers = []
100+
matches = GIT_DIFF_LINE_NUMBERS_PATTERN.finditer(output)
101+
102+
for match in matches:
103+
start = int(match.group(2))
104+
if match.group(4) is not None:
105+
end = start + int(match.group(4))
106+
line_numbers.extend(range(start, end))
107+
else:
108+
line_numbers.append(start)
109+
110+
return line_numbers
111+
112+
113+
def parse_filenames(output):
114+
return re.findall(GIT_DIFF_FILENAME_PATTERN, output)
76115

77-
_filename = ''
78-
lines = []
79116

117+
def split_diff_content_by_filename(output):
118+
"""
119+
Split the output by filename.
120+
121+
Args:
122+
output (int): ``git diff`` output.
123+
124+
Returns:
125+
dict: Filename and its content.
126+
"""
127+
content_by_filename = {}
128+
filenames = parse_filenames(output)
129+
splited_content = re.split(GIT_DIFF_SPLIT_PATTERN, output)
130+
splited_content = filter(lambda x: x != '', splited_content)
131+
132+
for filename, content in zip(filenames, splited_content):
133+
content_by_filename[filename] = content
134+
return content_by_filename
135+
136+
137+
def print_culprits(matches):
80138
exit_code = 0
139+
_filename = ''
140+
lines = []
81141

82-
for filename, test, match in matches:
142+
for filename, test, match, _ in matches:
83143
exit_code = test.error if exit_code == 0 else exit_code
144+
84145
if filename != _filename:
85146
_filename = filename
86147
lines = match.string.splitlines()
@@ -102,6 +163,46 @@ def main():
102163
)
103164
print(*match_lines, sep="\n")
104165

166+
return exit_code
167+
168+
169+
def match_with_diff_changes(content, matches):
170+
"""Check matches found on diff output."""
171+
for filename, test, match, line_number in matches:
172+
if content.get(filename) and line_number in content.get(filename):
173+
yield filename, test, match, line_number
174+
175+
176+
def parse_diff(output):
177+
"""Parse changed content by file."""
178+
changed_content = {}
179+
for filename, content in split_diff_content_by_filename(output).items():
180+
changed_line_numbers = parse_line_numbers(content)
181+
changed_content[filename] = changed_line_numbers
182+
return changed_content
183+
184+
185+
def main():
186+
args = parse_args()
187+
paths = {
188+
path
189+
for file in args.files
190+
for path in glob.iglob(file, recursive=True)
191+
}
192+
193+
tests = list(load_config(args.config))
194+
195+
matches = chain.from_iterable(
196+
lint_file(path, tests)
197+
for path in paths
198+
)
199+
200+
if args.diff:
201+
output = sys.stdin.read()
202+
changed_content = parse_diff(output)
203+
matches = match_with_diff_changes(changed_content, matches)
204+
205+
exit_code = print_culprits(matches)
105206
exit(exit_code)
106207

107208

test.diff

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
diff --git a/README.rst b/README.rst
2+
index 43032c5..e7203b3 100644
3+
--- a/README.rst
4+
+++ b/README.rst
5+
@@ -51,7 +51,7 @@ If you prefer linting changed files (cached on git) you can use the option
6+
7+
.. code-block:: bash
8+
9+
- relint my_file.py --diff
10+
+ git diff | relint my_file.py --diff
11+
12+
This option is useful for pre-commit purposes.
13+
14+
diff --git a/relint.py b/relint.py
15+
index 31061ec..697a3f0 100644
16+
--- a/relint.py
17+
+++ b/relint.py
18+
@@ -113,7 +113,7 @@ def print_culprits(matches):
19+
for filename, test, match, _ in matches:
20+
exit_code = test.error if exit_code == 0 else exit_code
21+
22+
- if filename != _filename:
23+
+ if filename != _filename: # TODO check this out
24+
_filename = filename
25+
lines = match.string.splitlines()
26+
27+
@@ -167,7 +167,7 @@ def main():
28+
for path in paths
29+
)
30+
31+
- if args.diff:
32+
+ if args.diff: # TODO wow
33+
output = sys.stdin.read()
34+
changed_content = parse_diff(output)
35+
matches = filter_paths_from_diff(changed_content, matches)
36+
diff --git a/test_relint.py b/test_relint.py
37+
index 7165fd3..249b783 100644
38+
--- a/test_relint.py
39+
+++ b/test_relint.py
40+
@@ -54,8 +54,9 @@ class TestParseGitDiff:
41+
def test_split_diff_content(self):
42+
output = open('test.diff').read()
43+
splited = split_diff_content(output)
44+
+
45+
assert isinstance(splited, dict)
46+
- assert len(splited) == 2
47+
+ assert len(splited) == 3
48+
49+
def test_return_empty_list_if_can_not_split_diff_content(self):
50+
splited = split_diff_content('')
51+
@@ -120,7 +121,7 @@ class TestParseGitDiff:
52+
"@@ -1,0 +2 @@\n" \
53+
"+# TODO: I'll do it later, promise\n"
54+
55+
- parsed_content = parse_diff(output)
56+
+ parsed_content = parse_diff(output) # TODO brand new
57+
expected = {'test_relint.py': [2]}
58+
59+
assert parsed_content == expected

0 commit comments

Comments
 (0)