Skip to content

Commit f939ecc

Browse files
committed
requirements_txt_fixer.py: Included an option to fail if no version is specified for a requirement
1 parent 31903ea commit f939ecc

File tree

4 files changed

+165
-120
lines changed

4 files changed

+165
-120
lines changed

.pre-commit-hooks.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@
192192
always_run: true
193193
- id: requirements-txt-fixer
194194
name: fix requirements.txt
195-
description: sorts entries in requirements.txt.
195+
description: sorts entries in requirements.txt and checks whether a version is specified (parameterized).
196196
entry: requirements-txt-fixer
197197
language: python
198198
files: (requirements|constraints).*\.txt$

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,10 @@ the following commandline options:
186186
- `--top-keys comma,separated,keys` - Keys to keep at the top of mappings.
187187

188188
#### `requirements-txt-fixer`
189-
Sorts entries in requirements.txt and constraints.txt and removes incorrect entry for `pkg-resources==0.0.0`
189+
Sorts entries in requirements.txt and constraints.txt and removes incorrect entry for `pkg-resources==0.0.0`
190+
Provides also an optional check if a version is specified for each requirement. You can configure this with
191+
the following commandline options:
192+
- `--fail-without-version` - Fails when no version is specified for a requirement
190193

191194
#### `sort-simple-yaml`
192195
Sorts simple YAML files which consist only of top-level

pre_commit_hooks/requirements_txt_fixer.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
class Requirement:
1414
UNTIL_COMPARISON = re.compile(b'={2,3}|!=|~=|>=?|<=?')
1515
UNTIL_SEP = re.compile(rb'[^;\s]+')
16+
VERSION_SPECIFIED = re.compile(b'.+(={2,3}|!=|~=|>=?|<=?).+')
1617

1718
def __init__(self) -> None:
1819
self.value: bytes | None = None
@@ -58,14 +59,17 @@ def is_complete(self) -> bool:
5859
not self.value.rstrip(b'\r\n').endswith(b'\\')
5960
)
6061

62+
def contains_version_specifier(self) -> bool:
63+
return bool(self.VERSION_SPECIFIED.match(self.value))
64+
6165
def append_value(self, value: bytes) -> None:
6266
if self.value is not None:
6367
self.value += value
6468
else:
6569
self.value = value
6670

6771

68-
def fix_requirements(f: IO[bytes]) -> int:
72+
def fix_requirements(f: IO[bytes], fail_without_version: bool) -> int:
6973
requirements: list[Requirement] = []
7074
before = list(f)
7175
after: list[bytes] = []
@@ -121,6 +125,17 @@ def fix_requirements(f: IO[bytes]) -> int:
121125
]
122126
]
123127

128+
# check for requirements without a version specified
129+
if fail_without_version:
130+
missing_requirement_found = False
131+
for req in requirements:
132+
if not req.contains_version_specifier():
133+
print(f'Missing version for requirement {req.name.decode()}')
134+
missing_requirement_found = True
135+
136+
if missing_requirement_found:
137+
return FAIL
138+
124139
# sort the requirements and remove duplicates
125140
prev = None
126141
for requirement in sorted(requirements):
@@ -145,13 +160,15 @@ def fix_requirements(f: IO[bytes]) -> int:
145160
def main(argv: Sequence[str] | None = None) -> int:
146161
parser = argparse.ArgumentParser()
147162
parser.add_argument('filenames', nargs='*', help='Filenames to fix')
163+
parser.add_argument("--fail-without-version", action="store_true",
164+
help="Fail if a requirement is missing a version")
148165
args = parser.parse_args(argv)
149166

150167
retv = PASS
151168

152169
for arg in args.filenames:
153170
with open(arg, 'rb+') as file_obj:
154-
ret_for_file = fix_requirements(file_obj)
171+
ret_for_file = fix_requirements(file_obj, args.fail_without_version)
155172

156173
if ret_for_file:
157174
print(f'Sorting {arg}')

tests/requirements_txt_fixer_test.py

Lines changed: 141 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -9,130 +9,155 @@
99

1010

1111
@pytest.mark.parametrize(
12-
('input_s', 'expected_retval', 'output'),
12+
('input_s', 'argv', 'expected_retval', 'output'),
1313
(
14-
(b'', PASS, b''),
15-
(b'\n', PASS, b'\n'),
16-
(b'# intentionally empty\n', PASS, b'# intentionally empty\n'),
17-
(b'foo\n# comment at end\n', PASS, b'foo\n# comment at end\n'),
18-
(b'foo\nbar\n', FAIL, b'bar\nfoo\n'),
19-
(b'bar\nfoo\n', PASS, b'bar\nfoo\n'),
20-
(b'a\nc\nb\n', FAIL, b'a\nb\nc\n'),
21-
(b'a\nc\nb', FAIL, b'a\nb\nc\n'),
22-
(b'a\nb\nc', FAIL, b'a\nb\nc\n'),
23-
(
24-
b'#comment1\nfoo\n#comment2\nbar\n',
25-
FAIL,
26-
b'#comment2\nbar\n#comment1\nfoo\n',
27-
),
28-
(
29-
b'#comment1\nbar\n#comment2\nfoo\n',
30-
PASS,
31-
b'#comment1\nbar\n#comment2\nfoo\n',
32-
),
33-
(b'#comment\n\nfoo\nbar\n', FAIL, b'#comment\n\nbar\nfoo\n'),
34-
(b'#comment\n\nbar\nfoo\n', PASS, b'#comment\n\nbar\nfoo\n'),
35-
(
36-
b'foo\n\t#comment with indent\nbar\n',
37-
FAIL,
38-
b'\t#comment with indent\nbar\nfoo\n',
39-
),
40-
(
41-
b'bar\n\t#comment with indent\nfoo\n',
42-
PASS,
43-
b'bar\n\t#comment with indent\nfoo\n',
44-
),
45-
(b'\nfoo\nbar\n', FAIL, b'bar\n\nfoo\n'),
46-
(b'\nbar\nfoo\n', PASS, b'\nbar\nfoo\n'),
47-
(
48-
b'pyramid-foo==1\npyramid>=2\n',
49-
FAIL,
50-
b'pyramid>=2\npyramid-foo==1\n',
51-
),
52-
(
53-
b'a==1\n'
54-
b'c>=1\n'
55-
b'bbbb!=1\n'
56-
b'c-a>=1;python_version>="3.6"\n'
57-
b'e>=2\n'
58-
b'd>2\n'
59-
b'g<2\n'
60-
b'f<=2\n',
61-
FAIL,
62-
b'a==1\n'
63-
b'bbbb!=1\n'
64-
b'c>=1\n'
65-
b'c-a>=1;python_version>="3.6"\n'
66-
b'd>2\n'
67-
b'e>=2\n'
68-
b'f<=2\n'
69-
b'g<2\n',
70-
),
71-
(b'a==1\nb==1\na==1\n', FAIL, b'a==1\nb==1\n'),
72-
(
73-
b'a==1\nb==1\n#comment about a\na==1\n',
74-
FAIL,
75-
b'#comment about a\na==1\nb==1\n',
76-
),
77-
(b'ocflib\nDjango\nPyMySQL\n', FAIL, b'Django\nocflib\nPyMySQL\n'),
78-
(
79-
b'-e git+ssh://git_url@tag#egg=ocflib\nDjango\nPyMySQL\n',
80-
FAIL,
81-
b'Django\n-e git+ssh://git_url@tag#egg=ocflib\nPyMySQL\n',
82-
),
83-
(b'bar\npkg-resources==0.0.0\nfoo\n', FAIL, b'bar\nfoo\n'),
84-
(b'foo\npkg-resources==0.0.0\nbar\n', FAIL, b'bar\nfoo\n'),
85-
(b'bar\npkg_resources==0.0.0\nfoo\n', FAIL, b'bar\nfoo\n'),
86-
(b'foo\npkg_resources==0.0.0\nbar\n', FAIL, b'bar\nfoo\n'),
87-
(
88-
b'git+ssh://git_url@tag#egg=ocflib\nDjango\nijk\n',
89-
FAIL,
90-
b'Django\nijk\ngit+ssh://git_url@tag#egg=ocflib\n',
91-
),
92-
(
93-
b'b==1.0.0\n'
94-
b'c=2.0.0 \\\n'
95-
b' --hash=sha256:abcd\n'
96-
b'a=3.0.0 \\\n'
97-
b' --hash=sha256:a1b1c1d1',
98-
FAIL,
99-
b'a=3.0.0 \\\n'
100-
b' --hash=sha256:a1b1c1d1\n'
101-
b'b==1.0.0\n'
102-
b'c=2.0.0 \\\n'
103-
b' --hash=sha256:abcd\n',
104-
),
105-
(
106-
b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n',
107-
PASS,
108-
b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n',
109-
),
14+
15+
(b'', [], PASS, b''),
16+
(b'\n', [], PASS, b'\n'),
17+
(b'# intentionally empty\n', [], PASS, b'# intentionally empty\n'),
18+
(b'foo\n# comment at end\n', [], PASS, b'foo\n# comment at end\n'),
19+
(b'foo\nbar\n', [], FAIL, b'bar\nfoo\n'),
20+
(b'bar\nfoo\n', [], PASS, b'bar\nfoo\n'),
21+
(b'a\nc\nb\n', [], FAIL, b'a\nb\nc\n'),
22+
(b'a\nc\nb', [], FAIL, b'a\nb\nc\n'),
23+
(b'a\nb\nc', [], FAIL, b'a\nb\nc\n'),
24+
(
25+
b'#comment1\nfoo\n#comment2\nbar\n',
26+
[],
27+
FAIL,
28+
b'#comment2\nbar\n#comment1\nfoo\n',
29+
),
30+
(
31+
b'#comment1\nbar\n#comment2\nfoo\n',
32+
[],
33+
PASS,
34+
b'#comment1\nbar\n#comment2\nfoo\n',
35+
),
36+
(b'#comment\n\nfoo\nbar\n', [], FAIL, b'#comment\n\nbar\nfoo\n'),
37+
(b'#comment\n\nbar\nfoo\n', [], PASS, b'#comment\n\nbar\nfoo\n'),
38+
(
39+
b'foo\n\t#comment with indent\nbar\n',
40+
[],
41+
FAIL,
42+
b'\t#comment with indent\nbar\nfoo\n',
43+
),
44+
(
45+
b'bar\n\t#comment with indent\nfoo\n',
46+
[],
47+
PASS,
48+
b'bar\n\t#comment with indent\nfoo\n',
49+
),
50+
(b'\nfoo\nbar\n', [], FAIL, b'bar\n\nfoo\n'),
51+
(b'\nbar\nfoo\n', [], PASS, b'\nbar\nfoo\n'),
52+
(
53+
b'pyramid-foo==1\npyramid>=2\n',
54+
[],
55+
FAIL,
56+
b'pyramid>=2\npyramid-foo==1\n',
57+
),
58+
(
59+
b'a==1\n'
60+
b'c>=1\n'
61+
b'bbbb!=1\n'
62+
b'c-a>=1;python_version>="3.6"\n'
63+
b'e>=2\n'
64+
b'd>2\n'
65+
b'g<2\n'
66+
b'f<=2\n',
67+
[],
68+
FAIL,
69+
b'a==1\n'
70+
b'bbbb!=1\n'
71+
b'c>=1\n'
72+
b'c-a>=1;python_version>="3.6"\n'
73+
b'd>2\n'
74+
b'e>=2\n'
75+
b'f<=2\n'
76+
b'g<2\n',
77+
),
78+
(b'a==1\nb==1\na==1\n', [], FAIL, b'a==1\nb==1\n'),
79+
(
80+
b'a==1\nb==1\n#comment about a\na==1\n',
81+
[],
82+
FAIL,
83+
b'#comment about a\na==1\nb==1\n',
84+
),
85+
(b'ocflib\nDjango\nPyMySQL\n', [], FAIL, b'Django\nocflib\nPyMySQL\n'),
86+
(
87+
b'-e git+ssh://git_url@tag#egg=ocflib\nDjango\nPyMySQL\n',
88+
[],
89+
FAIL,
90+
b'Django\n-e git+ssh://git_url@tag#egg=ocflib\nPyMySQL\n',
91+
),
92+
(b'bar\npkg-resources==0.0.0\nfoo\n', [], FAIL, b'bar\nfoo\n'),
93+
(b'foo\npkg-resources==0.0.0\nbar\n', [], FAIL, b'bar\nfoo\n'),
94+
(b'bar\npkg_resources==0.0.0\nfoo\n', [], FAIL, b'bar\nfoo\n'),
95+
(b'foo\npkg_resources==0.0.0\nbar\n', [], FAIL, b'bar\nfoo\n'),
96+
(
97+
b'git+ssh://git_url@tag#egg=ocflib\nDjango\nijk\n',
98+
[],
99+
FAIL,
100+
b'Django\nijk\ngit+ssh://git_url@tag#egg=ocflib\n',
101+
),
102+
(
103+
b'b==1.0.0\n'
104+
b'c=2.0.0 \\\n'
105+
b' --hash=sha256:abcd\n'
106+
b'a=3.0.0 \\\n'
107+
b' --hash=sha256:a1b1c1d1',
108+
[],
109+
FAIL,
110+
b'a=3.0.0 \\\n'
111+
b' --hash=sha256:a1b1c1d1\n'
112+
b'b==1.0.0\n'
113+
b'c=2.0.0 \\\n'
114+
b' --hash=sha256:abcd\n',
115+
),
116+
(
117+
b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n',
118+
[],
119+
PASS,
120+
b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n',
121+
),
122+
(b'bar\nfoo\n', ["--fail-without-version"], FAIL, b'bar\nfoo\n'),
123+
(b'bar==1.0\nfoo==1.1a\n', ["--fail-without-version"], PASS, b'bar==1.0\nfoo==1.1a\n'),
124+
(b'#test\nbar==1.0\nfoo==1.1a\n', ["--fail-without-version"], PASS, b'#test\nbar==1.0\nfoo==1.1a\n'),
125+
(b'bar==1.0\n#test\nfoo==1.1a\n', ["--fail-without-version"], PASS, b'bar==1.0\n#test\nfoo==1.1a\n'),
110126
),
111127
)
112-
def test_integration(input_s, expected_retval, output, tmpdir):
113-
path = tmpdir.join('file.txt')
114-
path.write_binary(input_s)
128+
def test_integration(input_s, argv, expected_retval, output, tmpdir):
129+
path = tmpdir.join('file.txt')
130+
path.write_binary(input_s)
115131

116-
output_retval = main([str(path)])
132+
output_retval = main([str(path)] + argv)
117133

118-
assert path.read_binary() == output
119-
assert output_retval == expected_retval
134+
assert path.read_binary() == output
135+
assert output_retval == expected_retval
120136

121137

122138
def test_requirement_object():
123-
top_of_file = Requirement()
124-
top_of_file.comments.append(b'#foo')
125-
top_of_file.value = b'\n'
139+
top_of_file = Requirement()
140+
top_of_file.comments.append(b'#foo')
141+
top_of_file.value = b'\n'
142+
143+
requirement_foo = Requirement()
144+
requirement_foo.value = b'foo'
145+
146+
requirement_bar = Requirement()
147+
requirement_bar.value = b'bar'
126148

127-
requirement_foo = Requirement()
128-
requirement_foo.value = b'foo'
149+
requirements_bar_versioned = Requirement()
150+
requirements_bar_versioned.value = b'bar==1.0'
129151

130-
requirement_bar = Requirement()
131-
requirement_bar.value = b'bar'
152+
# check for version specification
153+
assert top_of_file.contains_version_specifier() is False
154+
assert requirement_foo.contains_version_specifier() is False
155+
assert requirement_bar.contains_version_specifier() is False
156+
assert requirements_bar_versioned.contains_version_specifier() is True
132157

133-
# This may look redundant, but we need to test both foo.__lt__(bar) and
134-
# bar.__lt__(foo)
135-
assert requirement_foo > top_of_file
136-
assert top_of_file < requirement_foo
137-
assert requirement_foo > requirement_bar
138-
assert requirement_bar < requirement_foo
158+
# This may look redundant, but we need to test both foo.__lt__(bar) and
159+
# bar.__lt__(foo)
160+
assert requirement_foo > top_of_file
161+
assert top_of_file < requirement_foo
162+
assert requirement_foo > requirement_bar
163+
assert requirement_bar < requirement_foo

0 commit comments

Comments
 (0)