requirements_txt_fixer.py: Included an option to fail if no version is specified for a requirement

This commit is contained in:
Yannik Henke 2025-03-05 10:11:29 +01:00
parent 31903eabdb
commit f939ecc63d
4 changed files with 165 additions and 120 deletions

View file

@ -192,7 +192,7 @@
always_run: true always_run: true
- id: requirements-txt-fixer - id: requirements-txt-fixer
name: fix requirements.txt name: fix requirements.txt
description: sorts entries in requirements.txt. description: sorts entries in requirements.txt and checks whether a version is specified (parameterized).
entry: requirements-txt-fixer entry: requirements-txt-fixer
language: python language: python
files: (requirements|constraints).*\.txt$ files: (requirements|constraints).*\.txt$

View file

@ -187,6 +187,9 @@ the following commandline options:
#### `requirements-txt-fixer` #### `requirements-txt-fixer`
Sorts entries in requirements.txt and constraints.txt and removes incorrect entry for `pkg-resources==0.0.0` Sorts entries in requirements.txt and constraints.txt and removes incorrect entry for `pkg-resources==0.0.0`
Provides also an optional check if a version is specified for each requirement. You can configure this with
the following commandline options:
- `--fail-without-version` - Fails when no version is specified for a requirement
#### `sort-simple-yaml` #### `sort-simple-yaml`
Sorts simple YAML files which consist only of top-level Sorts simple YAML files which consist only of top-level

View file

@ -13,6 +13,7 @@ FAIL = 1
class Requirement: class Requirement:
UNTIL_COMPARISON = re.compile(b'={2,3}|!=|~=|>=?|<=?') UNTIL_COMPARISON = re.compile(b'={2,3}|!=|~=|>=?|<=?')
UNTIL_SEP = re.compile(rb'[^;\s]+') UNTIL_SEP = re.compile(rb'[^;\s]+')
VERSION_SPECIFIED = re.compile(b'.+(={2,3}|!=|~=|>=?|<=?).+')
def __init__(self) -> None: def __init__(self) -> None:
self.value: bytes | None = None self.value: bytes | None = None
@ -58,6 +59,9 @@ class Requirement:
not self.value.rstrip(b'\r\n').endswith(b'\\') not self.value.rstrip(b'\r\n').endswith(b'\\')
) )
def contains_version_specifier(self) -> bool:
return bool(self.VERSION_SPECIFIED.match(self.value))
def append_value(self, value: bytes) -> None: def append_value(self, value: bytes) -> None:
if self.value is not None: if self.value is not None:
self.value += value self.value += value
@ -65,7 +69,7 @@ class Requirement:
self.value = value self.value = value
def fix_requirements(f: IO[bytes]) -> int: def fix_requirements(f: IO[bytes], fail_without_version: bool) -> int:
requirements: list[Requirement] = [] requirements: list[Requirement] = []
before = list(f) before = list(f)
after: list[bytes] = [] after: list[bytes] = []
@ -121,6 +125,17 @@ def fix_requirements(f: IO[bytes]) -> int:
] ]
] ]
# check for requirements without a version specified
if fail_without_version:
missing_requirement_found = False
for req in requirements:
if not req.contains_version_specifier():
print(f'Missing version for requirement {req.name.decode()}')
missing_requirement_found = True
if missing_requirement_found:
return FAIL
# sort the requirements and remove duplicates # sort the requirements and remove duplicates
prev = None prev = None
for requirement in sorted(requirements): for requirement in sorted(requirements):
@ -145,13 +160,15 @@ def fix_requirements(f: IO[bytes]) -> int:
def main(argv: Sequence[str] | None = None) -> int: def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument('filenames', nargs='*', help='Filenames to fix') parser.add_argument('filenames', nargs='*', help='Filenames to fix')
parser.add_argument("--fail-without-version", action="store_true",
help="Fail if a requirement is missing a version")
args = parser.parse_args(argv) args = parser.parse_args(argv)
retv = PASS retv = PASS
for arg in args.filenames: for arg in args.filenames:
with open(arg, 'rb+') as file_obj: with open(arg, 'rb+') as file_obj:
ret_for_file = fix_requirements(file_obj) ret_for_file = fix_requirements(file_obj, args.fail_without_version)
if ret_for_file: if ret_for_file:
print(f'Sorting {arg}') print(f'Sorting {arg}')

View file

@ -9,43 +9,49 @@ from pre_commit_hooks.requirements_txt_fixer import Requirement
@pytest.mark.parametrize( @pytest.mark.parametrize(
('input_s', 'expected_retval', 'output'), ('input_s', 'argv', 'expected_retval', 'output'),
( (
(b'', PASS, b''),
(b'\n', PASS, b'\n'), (b'', [], PASS, b''),
(b'# intentionally empty\n', PASS, b'# intentionally empty\n'), (b'\n', [], PASS, b'\n'),
(b'foo\n# comment at end\n', PASS, b'foo\n# comment at end\n'), (b'# intentionally empty\n', [], PASS, b'# intentionally empty\n'),
(b'foo\nbar\n', FAIL, b'bar\nfoo\n'), (b'foo\n# comment at end\n', [], PASS, b'foo\n# comment at end\n'),
(b'bar\nfoo\n', PASS, b'bar\nfoo\n'), (b'foo\nbar\n', [], FAIL, b'bar\nfoo\n'),
(b'a\nc\nb\n', FAIL, b'a\nb\nc\n'), (b'bar\nfoo\n', [], PASS, b'bar\nfoo\n'),
(b'a\nc\nb', FAIL, b'a\nb\nc\n'), (b'a\nc\nb\n', [], FAIL, b'a\nb\nc\n'),
(b'a\nb\nc', FAIL, b'a\nb\nc\n'), (b'a\nc\nb', [], FAIL, b'a\nb\nc\n'),
(b'a\nb\nc', [], FAIL, b'a\nb\nc\n'),
( (
b'#comment1\nfoo\n#comment2\nbar\n', b'#comment1\nfoo\n#comment2\nbar\n',
[],
FAIL, FAIL,
b'#comment2\nbar\n#comment1\nfoo\n', b'#comment2\nbar\n#comment1\nfoo\n',
), ),
( (
b'#comment1\nbar\n#comment2\nfoo\n', b'#comment1\nbar\n#comment2\nfoo\n',
[],
PASS, PASS,
b'#comment1\nbar\n#comment2\nfoo\n', b'#comment1\nbar\n#comment2\nfoo\n',
), ),
(b'#comment\n\nfoo\nbar\n', FAIL, b'#comment\n\nbar\nfoo\n'), (b'#comment\n\nfoo\nbar\n', [], FAIL, b'#comment\n\nbar\nfoo\n'),
(b'#comment\n\nbar\nfoo\n', PASS, b'#comment\n\nbar\nfoo\n'), (b'#comment\n\nbar\nfoo\n', [], PASS, b'#comment\n\nbar\nfoo\n'),
( (
b'foo\n\t#comment with indent\nbar\n', b'foo\n\t#comment with indent\nbar\n',
[],
FAIL, FAIL,
b'\t#comment with indent\nbar\nfoo\n', b'\t#comment with indent\nbar\nfoo\n',
), ),
( (
b'bar\n\t#comment with indent\nfoo\n', b'bar\n\t#comment with indent\nfoo\n',
[],
PASS, PASS,
b'bar\n\t#comment with indent\nfoo\n', b'bar\n\t#comment with indent\nfoo\n',
), ),
(b'\nfoo\nbar\n', FAIL, b'bar\n\nfoo\n'), (b'\nfoo\nbar\n', [], FAIL, b'bar\n\nfoo\n'),
(b'\nbar\nfoo\n', PASS, b'\nbar\nfoo\n'), (b'\nbar\nfoo\n', [], PASS, b'\nbar\nfoo\n'),
( (
b'pyramid-foo==1\npyramid>=2\n', b'pyramid-foo==1\npyramid>=2\n',
[],
FAIL, FAIL,
b'pyramid>=2\npyramid-foo==1\n', b'pyramid>=2\npyramid-foo==1\n',
), ),
@ -58,6 +64,7 @@ from pre_commit_hooks.requirements_txt_fixer import Requirement
b'd>2\n' b'd>2\n'
b'g<2\n' b'g<2\n'
b'f<=2\n', b'f<=2\n',
[],
FAIL, FAIL,
b'a==1\n' b'a==1\n'
b'bbbb!=1\n' b'bbbb!=1\n'
@ -68,24 +75,27 @@ from pre_commit_hooks.requirements_txt_fixer import Requirement
b'f<=2\n' b'f<=2\n'
b'g<2\n', b'g<2\n',
), ),
(b'a==1\nb==1\na==1\n', FAIL, b'a==1\nb==1\n'), (b'a==1\nb==1\na==1\n', [], FAIL, b'a==1\nb==1\n'),
( (
b'a==1\nb==1\n#comment about a\na==1\n', b'a==1\nb==1\n#comment about a\na==1\n',
[],
FAIL, FAIL,
b'#comment about a\na==1\nb==1\n', b'#comment about a\na==1\nb==1\n',
), ),
(b'ocflib\nDjango\nPyMySQL\n', FAIL, b'Django\nocflib\nPyMySQL\n'), (b'ocflib\nDjango\nPyMySQL\n', [], FAIL, b'Django\nocflib\nPyMySQL\n'),
( (
b'-e git+ssh://git_url@tag#egg=ocflib\nDjango\nPyMySQL\n', b'-e git+ssh://git_url@tag#egg=ocflib\nDjango\nPyMySQL\n',
[],
FAIL, FAIL,
b'Django\n-e git+ssh://git_url@tag#egg=ocflib\nPyMySQL\n', b'Django\n-e git+ssh://git_url@tag#egg=ocflib\nPyMySQL\n',
), ),
(b'bar\npkg-resources==0.0.0\nfoo\n', FAIL, b'bar\nfoo\n'), (b'bar\npkg-resources==0.0.0\nfoo\n', [], FAIL, b'bar\nfoo\n'),
(b'foo\npkg-resources==0.0.0\nbar\n', FAIL, b'bar\nfoo\n'), (b'foo\npkg-resources==0.0.0\nbar\n', [], FAIL, b'bar\nfoo\n'),
(b'bar\npkg_resources==0.0.0\nfoo\n', FAIL, b'bar\nfoo\n'), (b'bar\npkg_resources==0.0.0\nfoo\n', [], FAIL, b'bar\nfoo\n'),
(b'foo\npkg_resources==0.0.0\nbar\n', FAIL, b'bar\nfoo\n'), (b'foo\npkg_resources==0.0.0\nbar\n', [], FAIL, b'bar\nfoo\n'),
( (
b'git+ssh://git_url@tag#egg=ocflib\nDjango\nijk\n', b'git+ssh://git_url@tag#egg=ocflib\nDjango\nijk\n',
[],
FAIL, FAIL,
b'Django\nijk\ngit+ssh://git_url@tag#egg=ocflib\n', b'Django\nijk\ngit+ssh://git_url@tag#egg=ocflib\n',
), ),
@ -95,6 +105,7 @@ from pre_commit_hooks.requirements_txt_fixer import Requirement
b' --hash=sha256:abcd\n' b' --hash=sha256:abcd\n'
b'a=3.0.0 \\\n' b'a=3.0.0 \\\n'
b' --hash=sha256:a1b1c1d1', b' --hash=sha256:a1b1c1d1',
[],
FAIL, FAIL,
b'a=3.0.0 \\\n' b'a=3.0.0 \\\n'
b' --hash=sha256:a1b1c1d1\n' b' --hash=sha256:a1b1c1d1\n'
@ -104,16 +115,21 @@ from pre_commit_hooks.requirements_txt_fixer import Requirement
), ),
( (
b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n', b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n',
[],
PASS, PASS,
b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n', b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n',
), ),
(b'bar\nfoo\n', ["--fail-without-version"], FAIL, b'bar\nfoo\n'),
(b'bar==1.0\nfoo==1.1a\n', ["--fail-without-version"], PASS, b'bar==1.0\nfoo==1.1a\n'),
(b'#test\nbar==1.0\nfoo==1.1a\n', ["--fail-without-version"], PASS, b'#test\nbar==1.0\nfoo==1.1a\n'),
(b'bar==1.0\n#test\nfoo==1.1a\n', ["--fail-without-version"], PASS, b'bar==1.0\n#test\nfoo==1.1a\n'),
), ),
) )
def test_integration(input_s, expected_retval, output, tmpdir): def test_integration(input_s, argv, expected_retval, output, tmpdir):
path = tmpdir.join('file.txt') path = tmpdir.join('file.txt')
path.write_binary(input_s) path.write_binary(input_s)
output_retval = main([str(path)]) output_retval = main([str(path)] + argv)
assert path.read_binary() == output assert path.read_binary() == output
assert output_retval == expected_retval assert output_retval == expected_retval
@ -130,6 +146,15 @@ def test_requirement_object():
requirement_bar = Requirement() requirement_bar = Requirement()
requirement_bar.value = b'bar' requirement_bar.value = b'bar'
requirements_bar_versioned = Requirement()
requirements_bar_versioned.value = b'bar==1.0'
# check for version specification
assert top_of_file.contains_version_specifier() is False
assert requirement_foo.contains_version_specifier() is False
assert requirement_bar.contains_version_specifier() is False
assert requirements_bar_versioned.contains_version_specifier() is True
# This may look redundant, but we need to test both foo.__lt__(bar) and # This may look redundant, but we need to test both foo.__lt__(bar) and
# bar.__lt__(foo) # bar.__lt__(foo)
assert requirement_foo > top_of_file assert requirement_foo > top_of_file