diff options
Diffstat (limited to '')
37 files changed, 2660 insertions, 0 deletions
diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/__init__.py diff --git a/tests/check_added_large_files_test.py b/tests/check_added_large_files_test.py new file mode 100644 index 0000000..54c4e68 --- /dev/null +++ b/tests/check_added_large_files_test.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +import shutil + +import pytest + +from pre_commit_hooks.check_added_large_files import find_large_added_files +from pre_commit_hooks.check_added_large_files import main +from pre_commit_hooks.util import cmd_output +from testing.util import git_commit + + +def test_nothing_added(temp_git_dir): + with temp_git_dir.as_cwd(): + assert find_large_added_files(['f.py'], 0) == 0 + + +def test_adding_something(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write("print('hello world')") + cmd_output('git', 'add', 'f.py') + + # Should fail with max size of 0 + assert find_large_added_files(['f.py'], 0) == 1 + + +def test_add_something_giant(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write('a' * 10000) + + # Should not fail when not added + assert find_large_added_files(['f.py'], 0) == 0 + + cmd_output('git', 'add', 'f.py') + + # Should fail with strict bound + assert find_large_added_files(['f.py'], 0) == 1 + + # Should also fail with actual bound + assert find_large_added_files(['f.py'], 9) == 1 + + # Should pass with higher bound + assert find_large_added_files(['f.py'], 10) == 0 + + +def test_enforce_all(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write('a' * 10000) + + # Should fail, when not staged with enforce_all + assert find_large_added_files(['f.py'], 0, enforce_all=True) == 1 + + # Should pass, when not staged without enforce_all + assert find_large_added_files(['f.py'], 0, enforce_all=False) == 0 + + +def test_added_file_not_in_pre_commits_list(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write("print('hello world')") + cmd_output('git', 'add', 'f.py') + + # Should pass even with a size of 0 + assert find_large_added_files(['g.py'], 0) == 0 + + +def test_integration(temp_git_dir): + with temp_git_dir.as_cwd(): + assert main(argv=[]) == 0 + + temp_git_dir.join('f.py').write('a' * 10000) + cmd_output('git', 'add', 'f.py') + + # Should not fail with default + assert main(argv=['f.py']) == 0 + + # Should fail with --maxkb + assert main(argv=['--maxkb', '9', 'f.py']) == 1 + + +def has_gitlfs(): + return shutil.which('git-lfs') is not None + + +xfailif_no_gitlfs = pytest.mark.xfail( + not has_gitlfs(), reason='This test requires git-lfs', +) + + +@xfailif_no_gitlfs +def test_allows_gitlfs(temp_git_dir): # pragma: no cover + with temp_git_dir.as_cwd(): + cmd_output('git', 'lfs', 'install', '--local') + temp_git_dir.join('f.py').write('a' * 10000) + cmd_output('git', 'lfs', 'track', 'f.py') + cmd_output('git', 'add', '--', '.') + # Should succeed + assert main(('--maxkb', '9', 'f.py')) == 0 + + +@xfailif_no_gitlfs +def test_moves_with_gitlfs(temp_git_dir): # pragma: no cover + with temp_git_dir.as_cwd(): + cmd_output('git', 'lfs', 'install', '--local') + cmd_output('git', 'lfs', 'track', 'a.bin', 'b.bin') + # First add the file we're going to move + temp_git_dir.join('a.bin').write('a' * 10000) + cmd_output('git', 'add', '--', '.') + git_commit('-am', 'foo') + # Now move it and make sure the hook still succeeds + cmd_output('git', 'mv', 'a.bin', 'b.bin') + assert main(('--maxkb', '9', 'b.bin')) == 0 + + +@xfailif_no_gitlfs +def test_enforce_allows_gitlfs(temp_git_dir): # pragma: no cover + with temp_git_dir.as_cwd(): + cmd_output('git', 'lfs', 'install', '--local') + temp_git_dir.join('f.py').write('a' * 10000) + cmd_output('git', 'lfs', 'track', 'f.py') + cmd_output('git', 'add', '--', '.') + # With --enforce-all large files on git lfs should succeed + assert main(('--enforce-all', '--maxkb', '9', 'f.py')) == 0 + + +@xfailif_no_gitlfs +def test_enforce_allows_gitlfs_after_commit(temp_git_dir): # pragma: no cover + with temp_git_dir.as_cwd(): + cmd_output('git', 'lfs', 'install', '--local') + temp_git_dir.join('f.py').write('a' * 10000) + cmd_output('git', 'lfs', 'track', 'f.py') + cmd_output('git', 'add', '--', '.') + git_commit('-am', 'foo') + # With --enforce-all large files on git lfs should succeed + assert main(('--enforce-all', '--maxkb', '9', 'f.py')) == 0 diff --git a/tests/check_ast_test.py b/tests/check_ast_test.py new file mode 100644 index 0000000..6243966 --- /dev/null +++ b/tests/check_ast_test.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from pre_commit_hooks.check_ast import main +from testing.util import get_resource_path + + +def test_failing_file(): + ret = main([get_resource_path('cannot_parse_ast.notpy')]) + assert ret == 1 + + +def test_passing_file(): + ret = main([__file__]) + assert ret == 0 diff --git a/tests/check_builtin_literals_test.py b/tests/check_builtin_literals_test.py new file mode 100644 index 0000000..1b18257 --- /dev/null +++ b/tests/check_builtin_literals_test.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import ast + +import pytest + +from pre_commit_hooks.check_builtin_literals import Call +from pre_commit_hooks.check_builtin_literals import main +from pre_commit_hooks.check_builtin_literals import Visitor + +BUILTIN_CONSTRUCTORS = '''\ +import builtins + +c1 = complex() +d1 = dict() +f1 = float() +i1 = int() +l1 = list() +s1 = str() +t1 = tuple() + +c2 = builtins.complex() +d2 = builtins.dict() +f2 = builtins.float() +i2 = builtins.int() +l2 = builtins.list() +s2 = builtins.str() +t2 = builtins.tuple() +''' +BUILTIN_LITERALS = '''\ +c1 = 0j +d1 = {} +f1 = 0.0 +i1 = 0 +l1 = [] +s1 = '' +t1 = () +''' + + +@pytest.fixture +def visitor(): + return Visitor() + + +@pytest.mark.parametrize( + ('expression', 'calls'), + [ + # see #285 + ('x[0]()', []), + # complex + ('0j', []), + ('complex()', [Call('complex', 1, 0)]), + ('complex(0, 0)', []), + ("complex('0+0j')", []), + ('builtins.complex()', []), + # float + ('0.0', []), + ('float()', [Call('float', 1, 0)]), + ("float('0.0')", []), + ('builtins.float()', []), + # int + ('0', []), + ('int()', [Call('int', 1, 0)]), + ("int('0')", []), + ('builtins.int()', []), + # list + ('[]', []), + ('list()', [Call('list', 1, 0)]), + ("list('abc')", []), + ("list([c for c in 'abc'])", []), + ("list(c for c in 'abc')", []), + ('builtins.list()', []), + # str + ("''", []), + ('str()', [Call('str', 1, 0)]), + ("str('0')", []), + ('builtins.str()', []), + # tuple + ('()', []), + ('tuple()', [Call('tuple', 1, 0)]), + ("tuple('abc')", []), + ("tuple([c for c in 'abc'])", []), + ("tuple(c for c in 'abc')", []), + ('builtins.tuple()', []), + ], +) +def test_non_dict_exprs(visitor, expression, calls): + visitor.visit(ast.parse(expression)) + assert visitor.builtin_type_calls == calls + + +@pytest.mark.parametrize( + ('expression', 'calls'), + [ + ('{}', []), + ('dict()', [Call('dict', 1, 0)]), + ('dict(a=1, b=2, c=3)', []), + ("dict(**{'a': 1, 'b': 2, 'c': 3})", []), + ("dict([(k, v) for k, v in [('a', 1), ('b', 2), ('c', 3)]])", []), + ("dict((k, v) for k, v in [('a', 1), ('b', 2), ('c', 3)])", []), + ('builtins.dict()', []), + ], +) +def test_dict_allow_kwargs_exprs(visitor, expression, calls): + visitor.visit(ast.parse(expression)) + assert visitor.builtin_type_calls == calls + + +@pytest.mark.parametrize( + ('expression', 'calls'), + [ + ('dict()', [Call('dict', 1, 0)]), + ('dict(a=1, b=2, c=3)', [Call('dict', 1, 0)]), + ("dict(**{'a': 1, 'b': 2, 'c': 3})", [Call('dict', 1, 0)]), + ('builtins.dict()', []), + ], +) +def test_dict_no_allow_kwargs_exprs(expression, calls): + visitor = Visitor(allow_dict_kwargs=False) + visitor.visit(ast.parse(expression)) + assert visitor.builtin_type_calls == calls + + +def test_ignore_constructors(): + visitor = Visitor( + ignore=('complex', 'dict', 'float', 'int', 'list', 'str', 'tuple'), + ) + visitor.visit(ast.parse(BUILTIN_CONSTRUCTORS)) + assert visitor.builtin_type_calls == [] + + +def test_failing_file(tmpdir): + f = tmpdir.join('f.py') + f.write(BUILTIN_CONSTRUCTORS) + rc = main([str(f)]) + assert rc == 1 + + +def test_passing_file(tmpdir): + f = tmpdir.join('f.py') + f.write(BUILTIN_LITERALS) + rc = main([str(f)]) + assert rc == 0 + + +def test_failing_file_ignore_all(tmpdir): + f = tmpdir.join('f.py') + f.write(BUILTIN_CONSTRUCTORS) + rc = main(['--ignore=complex,dict,float,int,list,str,tuple', str(f)]) + assert rc == 0 diff --git a/tests/check_byte_order_marker_test.py b/tests/check_byte_order_marker_test.py new file mode 100644 index 0000000..909a39b --- /dev/null +++ b/tests/check_byte_order_marker_test.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pre_commit_hooks import check_byte_order_marker + + +def test_failure(tmpdir): + f = tmpdir.join('f.txt') + f.write_text('ohai', encoding='utf-8-sig') + assert check_byte_order_marker.main((str(f),)) == 1 + + +def test_success(tmpdir): + f = tmpdir.join('f.txt') + f.write_text('ohai', encoding='utf-8') + assert check_byte_order_marker.main((str(f),)) == 0 diff --git a/tests/check_case_conflict_test.py b/tests/check_case_conflict_test.py new file mode 100644 index 0000000..a914f45 --- /dev/null +++ b/tests/check_case_conflict_test.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import sys + +import pytest + +from pre_commit_hooks.check_case_conflict import find_conflicting_filenames +from pre_commit_hooks.check_case_conflict import main +from pre_commit_hooks.check_case_conflict import parents +from pre_commit_hooks.util import cmd_output +from testing.util import git_commit + +skip_win32 = pytest.mark.skipif( + sys.platform == 'win32', + reason='case conflicts between directories and files', +) + + +def test_parents(): + assert set(parents('a')) == set() + assert set(parents('a/b')) == {'a'} + assert set(parents('a/b/c')) == {'a/b', 'a'} + assert set(parents('a/b/c/d')) == {'a/b/c', 'a/b', 'a'} + + +def test_nothing_added(temp_git_dir): + with temp_git_dir.as_cwd(): + assert find_conflicting_filenames(['f.py']) == 0 + + +def test_adding_something(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write("print('hello world')") + cmd_output('git', 'add', 'f.py') + + assert find_conflicting_filenames(['f.py']) == 0 + + +def test_adding_something_with_conflict(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write("print('hello world')") + cmd_output('git', 'add', 'f.py') + temp_git_dir.join('F.py').write("print('hello world')") + cmd_output('git', 'add', 'F.py') + + assert find_conflicting_filenames(['f.py', 'F.py']) == 1 + + +@skip_win32 # pragma: win32 no cover +def test_adding_files_with_conflicting_directories(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('dir').join('x').write('foo') + temp_git_dir.mkdir('DIR').join('y').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + +@skip_win32 # pragma: win32 no cover +def test_adding_files_with_conflicting_deep_directories(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('x').mkdir('y').join('z').write('foo') + temp_git_dir.join('X').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + +@skip_win32 # pragma: win32 no cover +def test_adding_file_with_conflicting_directory(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('dir').join('x').write('foo') + temp_git_dir.join('DIR').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + +def test_added_file_not_in_pre_commits_list(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write("print('hello world')") + cmd_output('git', 'add', 'f.py') + + assert find_conflicting_filenames(['g.py']) == 0 + + +def test_file_conflicts_with_committed_file(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.join('f.py').write("print('hello world')") + cmd_output('git', 'add', 'f.py') + git_commit('-m', 'Add f.py') + + temp_git_dir.join('F.py').write("print('hello world')") + cmd_output('git', 'add', 'F.py') + + assert find_conflicting_filenames(['F.py']) == 1 + + +@skip_win32 # pragma: win32 no cover +def test_file_conflicts_with_committed_dir(temp_git_dir): + with temp_git_dir.as_cwd(): + temp_git_dir.mkdir('dir').join('x').write('foo') + cmd_output('git', 'add', '-A') + git_commit('-m', 'Add f.py') + + temp_git_dir.join('DIR').write('foo') + cmd_output('git', 'add', '-A') + + assert find_conflicting_filenames([]) == 1 + + +def test_integration(temp_git_dir): + with temp_git_dir.as_cwd(): + assert main(argv=[]) == 0 + + temp_git_dir.join('f.py').write("print('hello world')") + cmd_output('git', 'add', 'f.py') + + assert main(argv=['f.py']) == 0 + + temp_git_dir.join('F.py').write("print('hello world')") + cmd_output('git', 'add', 'F.py') + + assert main(argv=['F.py']) == 1 diff --git a/tests/check_docstring_first_test.py b/tests/check_docstring_first_test.py new file mode 100644 index 0000000..8bafae8 --- /dev/null +++ b/tests/check_docstring_first_test.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.check_docstring_first import check_docstring_first +from pre_commit_hooks.check_docstring_first import main + + +# Contents, expected, expected_output +TESTS = ( + # trivial + (b'', 0, ''), + # Acceptable + (b'"foo"', 0, ''), + # Docstring after code + ( + b'from __future__ import unicode_literals\n' + b'"foo"\n', + 1, + '{filename}:2: Module docstring appears after code ' + '(code seen on line 1).\n', + ), + # Test double docstring + ( + b'"The real docstring"\n' + b'from __future__ import absolute_import\n' + b'"fake docstring"\n', + 1, + '{filename}:3: Multiple module docstrings ' + '(first docstring on line 1).\n', + ), + # Test multiple lines of code above + ( + b'import os\n' + b'import sys\n' + b'"docstring"\n', + 1, + '{filename}:3: Module docstring appears after code ' + '(code seen on line 1).\n', + ), + # String literals in expressions are ok. + (b'x = "foo"\n', 0, ''), +) + + +all_tests = pytest.mark.parametrize( + ('contents', 'expected', 'expected_out'), TESTS, +) + + +@all_tests +def test_unit(capsys, contents, expected, expected_out): + assert check_docstring_first(contents) == expected + assert capsys.readouterr()[0] == expected_out.format(filename='<unknown>') + + +@all_tests +def test_integration(tmpdir, capsys, contents, expected, expected_out): + f = tmpdir.join('test.py') + f.write_binary(contents) + assert main([str(f)]) == expected + assert capsys.readouterr()[0] == expected_out.format(filename=str(f)) + + +def test_arbitrary_encoding(tmpdir): + f = tmpdir.join('f.py') + contents = '# -*- coding: cp1252\nx = "£"'.encode('cp1252') + f.write_binary(contents) + assert main([str(f)]) == 0 diff --git a/tests/check_executables_have_shebangs_test.py b/tests/check_executables_have_shebangs_test.py new file mode 100644 index 0000000..82d03e3 --- /dev/null +++ b/tests/check_executables_have_shebangs_test.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import os +import sys + +import pytest + +from pre_commit_hooks import check_executables_have_shebangs +from pre_commit_hooks.check_executables_have_shebangs import main +from pre_commit_hooks.util import cmd_output + +skip_win32 = pytest.mark.skipif( + sys.platform == 'win32', + reason="non-git checks aren't relevant on windows", +) + + +@skip_win32 # pragma: win32 no cover +@pytest.mark.parametrize( + 'content', ( + b'#!/bin/bash\nhello world\n', + b'#!/usr/bin/env python3.6', + b'#!python', + '#!☃'.encode(), + ), +) +def test_has_shebang(content, tmpdir): + path = tmpdir.join('path') + path.write(content, 'wb') + assert main((str(path),)) == 0 + + +@skip_win32 # pragma: win32 no cover +@pytest.mark.parametrize( + 'content', ( + b'', + b' #!python\n', + b'\n#!python\n', + b'python\n', + '☃'.encode(), + ), +) +def test_bad_shebang(content, tmpdir, capsys): + path = tmpdir.join('path') + path.write(content, 'wb') + assert main((str(path),)) == 1 + _, stderr = capsys.readouterr() + assert stderr.startswith(f'{path}: marked executable but') + + +def test_check_git_filemode_passing(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('f') + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('chmod', '+x', f_path) + cmd_output('git', 'add', f_path) + cmd_output('git', 'update-index', '--chmod=+x', f_path) + + g = tmpdir.join('g').ensure() + g_path = str(g) + cmd_output('git', 'add', g_path) + + # this is potentially a problem, but not something the script intends + # to check for -- we're only making sure that things that are + # executable have shebangs + h = tmpdir.join('h') + h.write('#!/usr/bin/env bash') + h_path = str(h) + cmd_output('git', 'add', h_path) + + files = (f_path, g_path, h_path) + assert check_executables_have_shebangs._check_git_filemode(files) == 0 + + +def test_check_git_filemode_passing_unusual_characters(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('mañana.txt') + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('chmod', '+x', f_path) + cmd_output('git', 'add', f_path) + cmd_output('git', 'update-index', '--chmod=+x', f_path) + + files = (f_path,) + assert check_executables_have_shebangs._check_git_filemode(files) == 0 + + +def test_check_git_filemode_failing(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('f').ensure() + f_path = str(f) + cmd_output('chmod', '+x', f_path) + cmd_output('git', 'add', f_path) + cmd_output('git', 'update-index', '--chmod=+x', f_path) + + files = (f_path,) + assert check_executables_have_shebangs._check_git_filemode(files) == 1 + + +@pytest.mark.parametrize( + ('content', 'mode', 'expected'), + ( + pytest.param('#!python', '+x', 0, id='shebang with executable'), + pytest.param('#!python', '-x', 0, id='shebang without executable'), + pytest.param('', '+x', 1, id='no shebang with executable'), + pytest.param('', '-x', 0, id='no shebang without executable'), + ), +) +def test_git_executable_shebang(temp_git_dir, content, mode, expected): + with temp_git_dir.as_cwd(): + path = temp_git_dir.join('path') + path.write(content) + cmd_output('git', 'add', str(path)) + cmd_output('chmod', mode, str(path)) + cmd_output('git', 'update-index', f'--chmod={mode}', str(path)) + + # simulate how identify chooses that something is executable + filenames = [path for path in [str(path)] if os.access(path, os.X_OK)] + + assert main(filenames) == expected diff --git a/tests/check_json_test.py b/tests/check_json_test.py new file mode 100644 index 0000000..53e1f52 --- /dev/null +++ b/tests/check_json_test.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.check_json import main +from testing.util import get_resource_path + + +@pytest.mark.parametrize( + ('filename', 'expected_retval'), ( + ('bad_json.notjson', 1), + ('bad_json_latin1.nonjson', 1), + ('ok_json.json', 0), + ('duplicate_key_json.notjson', 1), + ), +) +def test_main(capsys, filename, expected_retval): + ret = main([get_resource_path(filename)]) + assert ret == expected_retval + if expected_retval == 1: + stdout, _ = capsys.readouterr() + assert filename in stdout + + +def test_non_utf8_file(tmpdir): + f = tmpdir.join('t.json') + f.write_binary(b'\xa9\xfe\x12') + assert main((str(f),)) diff --git a/tests/check_merge_conflict_test.py b/tests/check_merge_conflict_test.py new file mode 100644 index 0000000..76c4283 --- /dev/null +++ b/tests/check_merge_conflict_test.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import os +import shutil + +import pytest + +from pre_commit_hooks.check_merge_conflict import main +from pre_commit_hooks.util import cmd_output +from testing.util import get_resource_path +from testing.util import git_commit + + +@pytest.fixture +def f1_is_a_conflict_file(tmpdir): + # Make a merge conflict + repo1 = tmpdir.join('repo1') + repo1_f1 = repo1.join('f1') + repo2 = tmpdir.join('repo2') + repo2_f1 = repo2.join('f1') + + cmd_output('git', 'init', '--', str(repo1)) + with repo1.as_cwd(): + repo1_f1.ensure() + cmd_output('git', 'add', '.') + git_commit('-m', 'commit1') + + cmd_output('git', 'clone', str(repo1), str(repo2)) + + # Commit in master + with repo1.as_cwd(): + repo1_f1.write('parent\n') + git_commit('-am', 'master commit2') + + # Commit in clone and pull + with repo2.as_cwd(): + repo2_f1.write('child\n') + git_commit('-am', 'clone commit2') + cmd_output('git', 'pull', '--no-rebase', retcode=None) + # We should end up in a merge conflict! + f1 = repo2_f1.read() + assert f1.startswith( + '<<<<<<< HEAD\n' + 'child\n' + '=======\n' + 'parent\n' + '>>>>>>>', + ) or f1.startswith( + '<<<<<<< HEAD\n' + 'child\n' + # diff3 conflict style git merges add this line: + '||||||| merged common ancestors\n' + '=======\n' + 'parent\n' + '>>>>>>>', + ) or f1.startswith( + # .gitconfig with [pull] rebase = preserve causes a rebase which + # flips parent / child + '<<<<<<< HEAD\n' + 'parent\n' + '=======\n' + 'child\n' + '>>>>>>>', + ) + assert os.path.exists(os.path.join('.git', 'MERGE_MSG')) + yield repo2 + + +@pytest.fixture +def repository_pending_merge(tmpdir): + # Make a (non-conflicting) merge + repo1 = tmpdir.join('repo1') + repo1_f1 = repo1.join('f1') + repo2 = tmpdir.join('repo2') + repo2_f1 = repo2.join('f1') + repo2_f2 = repo2.join('f2') + cmd_output('git', 'init', str(repo1)) + with repo1.as_cwd(): + repo1_f1.ensure() + cmd_output('git', 'add', '.') + git_commit('-m', 'commit1') + + cmd_output('git', 'clone', str(repo1), str(repo2)) + + # Commit in master + with repo1.as_cwd(): + repo1_f1.write('parent\n') + git_commit('-am', 'master commit2') + + # Commit in clone and pull without committing + with repo2.as_cwd(): + repo2_f2.write('child\n') + cmd_output('git', 'add', '.') + git_commit('-m', 'clone commit2') + cmd_output('git', 'pull', '--no-commit', '--no-rebase') + # We should end up in a pending merge + assert repo2_f1.read() == 'parent\n' + assert repo2_f2.read() == 'child\n' + assert os.path.exists(os.path.join('.git', 'MERGE_HEAD')) + yield repo2 + + +@pytest.mark.usefixtures('f1_is_a_conflict_file') +def test_merge_conflicts_git(capsys): + assert main(['f1']) == 1 + out, _ = capsys.readouterr() + assert out == ( + "f1:1: Merge conflict string '<<<<<<<' found\n" + "f1:3: Merge conflict string '=======' found\n" + "f1:5: Merge conflict string '>>>>>>>' found\n" + ) + + +@pytest.mark.parametrize( + 'contents', (b'<<<<<<< HEAD\n', b'=======\n', b'>>>>>>> master\n'), +) +def test_merge_conflicts_failing(contents, repository_pending_merge): + repository_pending_merge.join('f2').write_binary(contents) + assert main(['f2']) == 1 + + +@pytest.mark.parametrize( + 'contents', (b'# <<<<<<< HEAD\n', b'# =======\n', b'import mod', b''), +) +def test_merge_conflicts_ok(contents, f1_is_a_conflict_file): + f1_is_a_conflict_file.join('f1').write_binary(contents) + assert main(['f1']) == 0 + + +@pytest.mark.usefixtures('f1_is_a_conflict_file') +def test_ignores_binary_files(): + shutil.copy(get_resource_path('img1.jpg'), 'f1') + assert main(['f1']) == 0 + + +def test_does_not_care_when_not_in_a_merge(tmpdir): + f = tmpdir.join('README.md') + f.write_binary(b'problem\n=======\n') + assert main([str(f.realpath())]) == 0 + + +def test_care_when_assumed_merge(tmpdir): + f = tmpdir.join('README.md') + f.write_binary(b'problem\n=======\n') + assert main([str(f.realpath()), '--assume-in-merge']) == 1 + + +def test_worktree_merge_conflicts(f1_is_a_conflict_file, tmpdir, capsys): + worktree = tmpdir.join('worktree') + cmd_output('git', 'worktree', 'add', str(worktree)) + with worktree.as_cwd(): + cmd_output( + 'git', 'pull', '--no-rebase', 'origin', 'master', retcode=None, + ) + msg = f1_is_a_conflict_file.join('.git/worktrees/worktree/MERGE_MSG') + assert msg.exists() + test_merge_conflicts_git(capsys) diff --git a/tests/check_shebang_scripts_are_executable_test.py b/tests/check_shebang_scripts_are_executable_test.py new file mode 100644 index 0000000..e4bd07c --- /dev/null +++ b/tests/check_shebang_scripts_are_executable_test.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import os + +import pytest + +from pre_commit_hooks.check_shebang_scripts_are_executable import \ + _check_git_filemode +from pre_commit_hooks.check_shebang_scripts_are_executable import main +from pre_commit_hooks.util import cmd_output + + +def test_check_git_filemode_passing(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('f') + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('chmod', '+x', f_path) + cmd_output('git', 'add', f_path) + cmd_output('git', 'update-index', '--chmod=+x', f_path) + + g = tmpdir.join('g').ensure() + g_path = str(g) + cmd_output('git', 'add', g_path) + + files = [f_path, g_path] + assert _check_git_filemode(files) == 0 + + # this is the one we should trigger on + h = tmpdir.join('h') + h.write('#!/usr/bin/env bash') + h_path = str(h) + cmd_output('git', 'add', h_path) + + files = [h_path] + assert _check_git_filemode(files) == 1 + + +def test_check_git_filemode_passing_unusual_characters(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('mañana.txt') + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('chmod', '+x', f_path) + cmd_output('git', 'add', f_path) + cmd_output('git', 'update-index', '--chmod=+x', f_path) + + files = (f_path,) + assert _check_git_filemode(files) == 0 + + +def test_check_git_filemode_failing(tmpdir): + with tmpdir.as_cwd(): + cmd_output('git', 'init', '.') + + f = tmpdir.join('f').ensure() + f.write('#!/usr/bin/env bash') + f_path = str(f) + cmd_output('git', 'add', f_path) + + files = (f_path,) + assert _check_git_filemode(files) == 1 + + +@pytest.mark.parametrize( + ('content', 'mode', 'expected'), + ( + pytest.param('#!python', '+x', 0, id='shebang with executable'), + pytest.param('#!python', '-x', 1, id='shebang without executable'), + pytest.param('', '+x', 0, id='no shebang with executable'), + pytest.param('', '-x', 0, id='no shebang without executable'), + ), +) +def test_git_executable_shebang(temp_git_dir, content, mode, expected): + with temp_git_dir.as_cwd(): + path = temp_git_dir.join('path') + path.write(content) + cmd_output('git', 'add', str(path)) + cmd_output('chmod', mode, str(path)) + cmd_output('git', 'update-index', f'--chmod={mode}', str(path)) + + # simulate how identify chooses that something is executable + filenames = [path for path in [str(path)] if os.access(path, os.X_OK)] + + assert main(filenames) == expected diff --git a/tests/check_symlinks_test.py b/tests/check_symlinks_test.py new file mode 100644 index 0000000..e2c2c78 --- /dev/null +++ b/tests/check_symlinks_test.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import os + +import pytest + +from pre_commit_hooks.check_symlinks import main + + +xfail_symlink = pytest.mark.xfail(os.name == 'nt', reason='No symlink support') + + +@xfail_symlink +@pytest.mark.parametrize( + ('dest', 'expected'), (('exists', 0), ('does-not-exist', 1)), +) +def test_main(tmpdir, dest, expected): # pragma: no cover (symlinks) + tmpdir.join('exists').ensure() + symlink = tmpdir.join('symlink') + symlink.mksymlinkto(tmpdir.join(dest)) + assert main((str(symlink),)) == expected + + +def test_main_normal_file(tmpdir): + assert main((str(tmpdir.join('f').ensure()),)) == 0 diff --git a/tests/check_toml_test.py b/tests/check_toml_test.py new file mode 100644 index 0000000..d594f81 --- /dev/null +++ b/tests/check_toml_test.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pre_commit_hooks.check_toml import main + + +def test_toml_bad(tmpdir): + filename = tmpdir.join('f') + filename.write(""" +key = # INVALID + += "no key name" # INVALID +""") + ret = main((str(filename),)) + assert ret == 1 + + +def test_toml_good(tmpdir): + filename = tmpdir.join('f') + filename.write( + """ +# This is a TOML document. + +title = "TOML Example" + +[owner] +name = "John" +dob = 1979-05-27T07:32:00-08:00 # First class dates +""", + ) + ret = main((str(filename),)) + assert ret == 0 + + +def test_toml_good_unicode(tmpdir): + filename = tmpdir.join('f') + filename.write_binary('letter = "\N{SNOWMAN}"\n'.encode()) + ret = main((str(filename),)) + assert ret == 0 diff --git a/tests/check_vcs_permalinks_test.py b/tests/check_vcs_permalinks_test.py new file mode 100644 index 0000000..01ce94d --- /dev/null +++ b/tests/check_vcs_permalinks_test.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pre_commit_hooks.check_vcs_permalinks import main + + +def test_trivial(tmpdir): + f = tmpdir.join('f.txt').ensure() + assert not main((str(f),)) + + +def test_passing(tmpdir): + f = tmpdir.join('f.txt') + f.write_binary( + # permalinks are ok + b'https://github.com/asottile/test/blob/649e6/foo%20bar#L1\n' + # tags are ok + b'https://github.com/asottile/test/blob/1.0.0/foo%20bar#L1\n' + # links to files but not line numbers are ok + b'https://github.com/asottile/test/blob/master/foo%20bar\n' + # regression test for overly-greedy regex + b'https://github.com/ yes / no ? /blob/master/foo#L1\n', + ) + assert not main((str(f),)) + + +def test_failing(tmpdir, capsys): + with tmpdir.as_cwd(): + tmpdir.join('f.txt').write_binary( + b'https://github.com/asottile/test/blob/master/foo#L1\n' + b'https://example.com/asottile/test/blob/master/foo#L1\n' + b'https://example.com/asottile/test/blob/main/foo#L1\n', + ) + + assert main(('f.txt', '--additional-github-domain', 'example.com')) + out, _ = capsys.readouterr() + assert out == ( + 'f.txt:1:https://github.com/asottile/test/blob/master/foo#L1\n' + 'f.txt:2:https://example.com/asottile/test/blob/master/foo#L1\n' + 'f.txt:3:https://example.com/asottile/test/blob/main/foo#L1\n' + '\n' + 'Non-permanent github link detected.\n' + 'On any page on github press [y] to load a permalink.\n' + ) diff --git a/tests/check_xml_test.py b/tests/check_xml_test.py new file mode 100644 index 0000000..767619f --- /dev/null +++ b/tests/check_xml_test.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.check_xml import main +from testing.util import get_resource_path + + +@pytest.mark.parametrize( + ('filename', 'expected_retval'), ( + ('bad_xml.notxml', 1), + ('ok_xml.xml', 0), + ), +) +def test_main(filename, expected_retval): + ret = main([get_resource_path(filename)]) + assert ret == expected_retval diff --git a/tests/check_yaml_test.py b/tests/check_yaml_test.py new file mode 100644 index 0000000..54eb16e --- /dev/null +++ b/tests/check_yaml_test.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.check_yaml import main +from testing.util import get_resource_path + + +@pytest.mark.parametrize( + ('filename', 'expected_retval'), ( + ('bad_yaml.notyaml', 1), + ('ok_yaml.yaml', 0), + ), +) +def test_main(filename, expected_retval): + ret = main([get_resource_path(filename)]) + assert ret == expected_retval + + +def test_main_allow_multiple_documents(tmpdir): + f = tmpdir.join('test.yaml') + f.write('---\nfoo\n---\nbar\n') + + # should fail without the setting + assert main((str(f),)) + + # should pass when we allow multiple documents + assert not main(('--allow-multiple-documents', str(f))) + + +def test_fails_even_with_allow_multiple_documents(tmpdir): + f = tmpdir.join('test.yaml') + f.write('[') + assert main(('--allow-multiple-documents', str(f))) + + +def test_main_unsafe(tmpdir): + f = tmpdir.join('test.yaml') + f.write( + 'some_foo: !vault |\n' + ' $ANSIBLE_VAULT;1.1;AES256\n' + ' deadbeefdeadbeefdeadbeef\n', + ) + # should fail "safe" check + assert main((str(f),)) + # should pass when we allow unsafe documents + assert not main(('--unsafe', str(f))) + + +def test_main_unsafe_still_fails_on_syntax_errors(tmpdir): + f = tmpdir.join('test.yaml') + f.write('[') + assert main(('--unsafe', str(f))) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..807f15b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.util import cmd_output + + +@pytest.fixture +def temp_git_dir(tmpdir): + git_dir = tmpdir.join('gits') + cmd_output('git', 'init', '--', str(git_dir)) + yield git_dir diff --git a/tests/debug_statement_hook_test.py b/tests/debug_statement_hook_test.py new file mode 100644 index 0000000..5a8e0bb --- /dev/null +++ b/tests/debug_statement_hook_test.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import ast + +from pre_commit_hooks.debug_statement_hook import Debug +from pre_commit_hooks.debug_statement_hook import DebugStatementParser +from pre_commit_hooks.debug_statement_hook import main +from testing.util import get_resource_path + + +def test_no_breakpoints(): + visitor = DebugStatementParser() + visitor.visit(ast.parse('import os\nfrom foo import bar\n')) + assert visitor.breakpoints == [] + + +def test_finds_debug_import_attribute_access(): + visitor = DebugStatementParser() + visitor.visit(ast.parse('import ipdb; ipdb.set_trace()')) + assert visitor.breakpoints == [Debug(1, 0, 'ipdb', 'imported')] + + +def test_finds_debug_import_from_import(): + visitor = DebugStatementParser() + visitor.visit(ast.parse('from pudb import set_trace; set_trace()')) + assert visitor.breakpoints == [Debug(1, 0, 'pudb', 'imported')] + + +def test_finds_breakpoint(): + visitor = DebugStatementParser() + visitor.visit(ast.parse('breakpoint()')) + assert visitor.breakpoints == [Debug(1, 0, 'breakpoint', 'called')] + + +def test_returns_one_for_failing_file(tmpdir): + f_py = tmpdir.join('f.py') + f_py.write('def f():\n import pdb; pdb.set_trace()') + ret = main([str(f_py)]) + assert ret == 1 + + +def test_returns_zero_for_passing_file(): + ret = main([__file__]) + assert ret == 0 + + +def test_syntaxerror_file(): + ret = main([get_resource_path('cannot_parse_ast.notpy')]) + assert ret == 1 + + +def test_non_utf8_file(tmpdir): + f_py = tmpdir.join('f.py') + f_py.write_binary('# -*- coding: cp1252 -*-\nx = "€"\n'.encode('cp1252')) + assert main((str(f_py),)) == 0 + + +def test_py37_breakpoint(tmpdir, capsys): + f_py = tmpdir.join('f.py') + f_py.write('def f():\n breakpoint()\n') + assert main((str(f_py),)) == 1 + out, _ = capsys.readouterr() + assert out == f'{f_py}:2:4: breakpoint called\n' diff --git a/tests/destroyed_symlinks_test.py b/tests/destroyed_symlinks_test.py new file mode 100644 index 0000000..39c474a --- /dev/null +++ b/tests/destroyed_symlinks_test.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import os +import subprocess + +import pytest + +from pre_commit_hooks.destroyed_symlinks import find_destroyed_symlinks +from pre_commit_hooks.destroyed_symlinks import main +from testing.util import git_commit + +TEST_SYMLINK = 'test_symlink' +TEST_SYMLINK_TARGET = '/doesnt/really/matters' +TEST_FILE = 'test_file' +TEST_FILE_RENAMED = f'{TEST_FILE}_renamed' + + +@pytest.fixture +def repo_with_destroyed_symlink(tmpdir): + source_repo = tmpdir.join('src') + os.makedirs(source_repo, exist_ok=True) + test_repo = tmpdir.join('test') + with source_repo.as_cwd(): + subprocess.check_call(('git', 'init')) + os.symlink(TEST_SYMLINK_TARGET, TEST_SYMLINK) + with open(TEST_FILE, 'w') as f: + print('some random content', file=f) + subprocess.check_call(('git', 'add', '.')) + git_commit('-m', 'initial') + assert b'120000 ' in subprocess.check_output( + ('git', 'cat-file', '-p', 'HEAD^{tree}'), + ) + subprocess.check_call( + ('git', '-c', 'core.symlinks=false', 'clone', source_repo, test_repo), + ) + with test_repo.as_cwd(): + subprocess.check_call( + ('git', 'config', '--local', 'core.symlinks', 'true'), + ) + subprocess.check_call(('git', 'mv', TEST_FILE, TEST_FILE_RENAMED)) + assert not os.path.islink(test_repo.join(TEST_SYMLINK)) + yield test_repo + + +def test_find_destroyed_symlinks(repo_with_destroyed_symlink): + with repo_with_destroyed_symlink.as_cwd(): + assert find_destroyed_symlinks([]) == [] + assert main([]) == 0 + + subprocess.check_call(('git', 'add', TEST_SYMLINK)) + assert find_destroyed_symlinks([TEST_SYMLINK]) == [TEST_SYMLINK] + assert find_destroyed_symlinks([]) == [] + assert main([]) == 0 + assert find_destroyed_symlinks([TEST_FILE_RENAMED, TEST_FILE]) == [] + ALL_STAGED = [TEST_SYMLINK, TEST_FILE_RENAMED] + assert find_destroyed_symlinks(ALL_STAGED) == [TEST_SYMLINK] + assert main(ALL_STAGED) != 0 + + with open(TEST_SYMLINK, 'a') as f: + print(file=f) # add trailing newline + subprocess.check_call(['git', 'add', TEST_SYMLINK]) + assert find_destroyed_symlinks(ALL_STAGED) == [TEST_SYMLINK] + assert main(ALL_STAGED) != 0 + + with open(TEST_SYMLINK, 'w') as f: + print('0' * len(TEST_SYMLINK_TARGET), file=f) + subprocess.check_call(('git', 'add', TEST_SYMLINK)) + assert find_destroyed_symlinks(ALL_STAGED) == [] + assert main(ALL_STAGED) == 0 + + with open(TEST_SYMLINK, 'w') as f: + print('0' * (len(TEST_SYMLINK_TARGET) + 3), file=f) + subprocess.check_call(('git', 'add', TEST_SYMLINK)) + assert find_destroyed_symlinks(ALL_STAGED) == [] + assert main(ALL_STAGED) == 0 diff --git a/tests/detect_aws_credentials_test.py b/tests/detect_aws_credentials_test.py new file mode 100644 index 0000000..afda47a --- /dev/null +++ b/tests/detect_aws_credentials_test.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from pre_commit_hooks.detect_aws_credentials import get_aws_cred_files_from_env +from pre_commit_hooks.detect_aws_credentials import get_aws_secrets_from_env +from pre_commit_hooks.detect_aws_credentials import get_aws_secrets_from_file +from pre_commit_hooks.detect_aws_credentials import main +from testing.util import get_resource_path + + +@pytest.mark.parametrize( + ('env_vars', 'values'), + ( + ({}, set()), + ({'AWS_PLACEHOLDER_KEY': '/foo'}, set()), + ({'AWS_CONFIG_FILE': '/foo'}, {'/foo'}), + ({'AWS_CREDENTIAL_FILE': '/foo'}, {'/foo'}), + ({'AWS_SHARED_CREDENTIALS_FILE': '/foo'}, {'/foo'}), + ({'BOTO_CONFIG': '/foo'}, {'/foo'}), + ({'AWS_PLACEHOLDER_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar'}, {'/bar'}), + ( + { + 'AWS_PLACEHOLDER_KEY': '/foo', 'AWS_CONFIG_FILE': '/bar', + 'AWS_CREDENTIAL_FILE': '/baz', + }, + {'/bar', '/baz'}, + ), + ( + { + 'AWS_CONFIG_FILE': '/foo', 'AWS_CREDENTIAL_FILE': '/bar', + 'AWS_SHARED_CREDENTIALS_FILE': '/baz', + }, + {'/foo', '/bar', '/baz'}, + ), + ), +) +def test_get_aws_credentials_file_from_env(env_vars, values): + with patch.dict('os.environ', env_vars, clear=True): + assert get_aws_cred_files_from_env() == values + + +@pytest.mark.parametrize( + ('env_vars', 'values'), + ( + ({}, set()), + ({'AWS_PLACEHOLDER_KEY': 'foo'}, set()), + ({'AWS_SECRET_ACCESS_KEY': 'foo'}, {'foo'}), + ({'AWS_SECURITY_TOKEN': 'foo'}, {'foo'}), + ({'AWS_SESSION_TOKEN': 'foo'}, {'foo'}), + ({'AWS_SESSION_TOKEN': ''}, set()), + ({'AWS_SESSION_TOKEN': 'foo', 'AWS_SECURITY_TOKEN': ''}, {'foo'}), + ( + {'AWS_PLACEHOLDER_KEY': 'foo', 'AWS_SECRET_ACCESS_KEY': 'bar'}, + {'bar'}, + ), + ( + {'AWS_SECRET_ACCESS_KEY': 'foo', 'AWS_SECURITY_TOKEN': 'bar'}, + {'foo', 'bar'}, + ), + ), +) +def test_get_aws_secrets_from_env(env_vars, values): + """Test that reading secrets from environment variables works.""" + with patch.dict('os.environ', env_vars, clear=True): + assert get_aws_secrets_from_env() == values + + +@pytest.mark.parametrize( + ('filename', 'expected_keys'), + ( + ( + 'aws_config_with_secret.ini', + {'z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb'}, + ), + ('aws_config_with_session_token.ini', {'foo'}), + ( + 'aws_config_with_secret_and_session_token.ini', + {'z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb', 'foo'}, + ), + ( + 'aws_config_with_multiple_sections.ini', + { + '7xebzorgm5143ouge9gvepxb2z70bsb2rtrh099e', + 'z2rpgs5uit782eapz5l1z0y2lurtsyyk6hcfozlb', + 'ixswosj8gz3wuik405jl9k3vdajsnxfhnpui38ez', + 'foo', + }, + ), + ('aws_config_without_secrets.ini', set()), + ('aws_config_without_secrets_with_spaces.ini', set()), + ('nonsense.txt', set()), + ('ok_json.json', set()), + ), +) +def test_get_aws_secrets_from_file(filename, expected_keys): + """Test that reading secrets from files works.""" + keys = get_aws_secrets_from_file(get_resource_path(filename)) + assert keys == expected_keys + + +@pytest.mark.parametrize( + ('filename', 'expected_retval'), + ( + ('aws_config_with_secret.ini', 1), + ('aws_config_with_session_token.ini', 1), + ('aws_config_with_multiple_sections.ini', 1), + ('aws_config_without_secrets.ini', 0), + ('aws_config_without_secrets_with_spaces.ini', 0), + ('nonsense.txt', 0), + ('ok_json.json', 0), + ), +) +def test_detect_aws_credentials(filename, expected_retval): + # with a valid credentials file + ret = main(( + get_resource_path(filename), + '--credentials-file', + 'testing/resources/aws_config_with_multiple_sections.ini', + )) + assert ret == expected_retval + + +def test_allows_arbitrarily_encoded_files(tmpdir): + src_ini = tmpdir.join('src.ini') + src_ini.write( + '[default]\n' + 'aws_access_key_id=AKIASDFASDF\n' + 'aws_secret_Access_key=9018asdf23908190238123\n', + ) + arbitrary_encoding = tmpdir.join('f') + arbitrary_encoding.write_binary(b'\x12\x9a\xe2\xf2') + ret = main((str(arbitrary_encoding), '--credentials-file', str(src_ini))) + assert ret == 0 + + +@patch('pre_commit_hooks.detect_aws_credentials.get_aws_secrets_from_file') +@patch('pre_commit_hooks.detect_aws_credentials.get_aws_secrets_from_env') +def test_non_existent_credentials(mock_secrets_env, mock_secrets_file, capsys): + """Test behavior with no configured AWS secrets.""" + mock_secrets_env.return_value = set() + mock_secrets_file.return_value = set() + ret = main(( + get_resource_path('aws_config_without_secrets.ini'), + '--credentials-file=testing/resources/credentailsfilethatdoesntexist', + )) + assert ret == 2 + out, _ = capsys.readouterr() + assert out == ( + 'No AWS keys were found in the configured credential files ' + 'and environment variables.\nPlease ensure you have the ' + 'correct setting for --credentials-file\n' + ) + + +@patch('pre_commit_hooks.detect_aws_credentials.get_aws_secrets_from_file') +@patch('pre_commit_hooks.detect_aws_credentials.get_aws_secrets_from_env') +def test_non_existent_credentials_with_allow_flag( + mock_secrets_env, mock_secrets_file, +): + mock_secrets_env.return_value = set() + mock_secrets_file.return_value = set() + ret = main(( + get_resource_path('aws_config_without_secrets.ini'), + '--credentials-file=testing/resources/credentailsfilethatdoesntexist', + '--allow-missing-credentials', + )) + assert ret == 0 diff --git a/tests/detect_private_key_test.py b/tests/detect_private_key_test.py new file mode 100644 index 0000000..41f8bae --- /dev/null +++ b/tests/detect_private_key_test.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.detect_private_key import main + +# Input, expected return value +TESTS = ( + (b'-----BEGIN RSA PRIVATE KEY-----', 1), + (b'-----BEGIN DSA PRIVATE KEY-----', 1), + (b'-----BEGIN EC PRIVATE KEY-----', 1), + (b'-----BEGIN OPENSSH PRIVATE KEY-----', 1), + (b'PuTTY-User-Key-File-2: ssh-rsa', 1), + (b'---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----', 1), + (b'-----BEGIN ENCRYPTED PRIVATE KEY-----', 1), + (b'-----BEGIN OpenVPN Static key V1-----', 1), + (b'ssh-rsa DATA', 0), + (b'ssh-dsa DATA', 0), + # Some arbitrary binary data + (b'\xa2\xf1\x93\x12', 0), +) + + +@pytest.mark.parametrize(('input_s', 'expected_retval'), TESTS) +def test_main(input_s, expected_retval, tmpdir): + path = tmpdir.join('file.txt') + path.write_binary(input_s) + assert main([str(path)]) == expected_retval diff --git a/tests/end_of_file_fixer_test.py b/tests/end_of_file_fixer_test.py new file mode 100644 index 0000000..8a5d889 --- /dev/null +++ b/tests/end_of_file_fixer_test.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import io + +import pytest + +from pre_commit_hooks.end_of_file_fixer import fix_file +from pre_commit_hooks.end_of_file_fixer import main + + +# Input, expected return value, expected output +TESTS = ( + (b'foo\n', 0, b'foo\n'), + (b'', 0, b''), + (b'\n\n', 1, b''), + (b'\n\n\n\n', 1, b''), + (b'foo', 1, b'foo\n'), + (b'foo\n\n\n', 1, b'foo\n'), + (b'\xe2\x98\x83', 1, b'\xe2\x98\x83\n'), + (b'foo\r\n', 0, b'foo\r\n'), + (b'foo\r\n\r\n\r\n', 1, b'foo\r\n'), + (b'foo\r', 0, b'foo\r'), + (b'foo\r\r\r\r', 1, b'foo\r'), +) + + +@pytest.mark.parametrize(('input_s', 'expected_retval', 'output'), TESTS) +def test_fix_file(input_s, expected_retval, output): + file_obj = io.BytesIO(input_s) + ret = fix_file(file_obj) + assert file_obj.getvalue() == output + assert ret == expected_retval + + +@pytest.mark.parametrize(('input_s', 'expected_retval', 'output'), TESTS) +def test_integration(input_s, expected_retval, output, tmpdir): + path = tmpdir.join('file.txt') + path.write_binary(input_s) + + ret = main([str(path)]) + file_output = path.read_binary() + + assert file_output == output + assert ret == expected_retval diff --git a/tests/file_contents_sorter_test.py b/tests/file_contents_sorter_test.py new file mode 100644 index 0000000..49b3b79 --- /dev/null +++ b/tests/file_contents_sorter_test.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.file_contents_sorter import FAIL +from pre_commit_hooks.file_contents_sorter import main +from pre_commit_hooks.file_contents_sorter import PASS + + +@pytest.mark.parametrize( + ('input_s', 'argv', 'expected_retval', 'output'), + ( + (b'', [], PASS, b''), + (b'\n', [], FAIL, b''), + (b'\n\n', [], FAIL, b''), + (b'lonesome\n', [], PASS, b'lonesome\n'), + (b'missing_newline', [], FAIL, b'missing_newline\n'), + (b'newline\nmissing', [], FAIL, b'missing\nnewline\n'), + (b'missing\nnewline', [], FAIL, b'missing\nnewline\n'), + (b'alpha\nbeta\n', [], PASS, b'alpha\nbeta\n'), + (b'beta\nalpha\n', [], FAIL, b'alpha\nbeta\n'), + (b'C\nc\n', [], PASS, b'C\nc\n'), + (b'c\nC\n', [], FAIL, b'C\nc\n'), + (b'mag ical \n tre vor\n', [], FAIL, b' tre vor\nmag ical \n'), + (b'@\n-\n_\n#\n', [], FAIL, b'#\n-\n@\n_\n'), + (b'extra\n\n\nwhitespace\n', [], FAIL, b'extra\nwhitespace\n'), + (b'whitespace\n\n\nextra\n', [], FAIL, b'extra\nwhitespace\n'), + ( + b'fee\nFie\nFoe\nfum\n', + [], + FAIL, + b'Fie\nFoe\nfee\nfum\n', + ), + ( + b'Fie\nFoe\nfee\nfum\n', + [], + PASS, + b'Fie\nFoe\nfee\nfum\n', + ), + ( + b'fee\nFie\nFoe\nfum\n', + ['--ignore-case'], + PASS, + b'fee\nFie\nFoe\nfum\n', + ), + ( + b'Fie\nFoe\nfee\nfum\n', + ['--ignore-case'], + FAIL, + b'fee\nFie\nFoe\nfum\n', + ), + ( + b'Fie\nFoe\nfee\nfee\nfum\n', + ['--ignore-case'], + FAIL, + b'fee\nfee\nFie\nFoe\nfum\n', + ), + ( + b'Fie\nFoe\nfee\nfum\n', + ['--unique'], + PASS, + b'Fie\nFoe\nfee\nfum\n', + ), + ( + b'Fie\nFie\nFoe\nfee\nfum\n', + ['--unique'], + FAIL, + b'Fie\nFoe\nfee\nfum\n', + ), + ( + b'fee\nFie\nFoe\nfum\n', + ['--unique', '--ignore-case'], + PASS, + b'fee\nFie\nFoe\nfum\n', + ), + ( + b'fee\nfee\nFie\nFoe\nfum\n', + ['--unique', '--ignore-case'], + FAIL, + b'fee\nFie\nFoe\nfum\n', + ), + ), +) +def test_integration(input_s, argv, expected_retval, output, tmpdir): + path = tmpdir.join('file.txt') + path.write_binary(input_s) + + output_retval = main([str(path)] + argv) + + assert path.read_binary() == output + assert output_retval == expected_retval diff --git a/tests/fix_byte_order_marker_test.py b/tests/fix_byte_order_marker_test.py new file mode 100644 index 0000000..d7a6599 --- /dev/null +++ b/tests/fix_byte_order_marker_test.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pre_commit_hooks import fix_byte_order_marker + + +def test_failure(tmpdir): + f = tmpdir.join('f.txt') + f.write_text('ohai', encoding='utf-8-sig') + assert fix_byte_order_marker.main((str(f),)) == 1 + + +def test_success(tmpdir): + f = tmpdir.join('f.txt') + f.write_text('ohai', encoding='utf-8') + assert fix_byte_order_marker.main((str(f),)) == 0 diff --git a/tests/fix_encoding_pragma_test.py b/tests/fix_encoding_pragma_test.py new file mode 100644 index 0000000..98557e9 --- /dev/null +++ b/tests/fix_encoding_pragma_test.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import io + +import pytest + +from pre_commit_hooks.fix_encoding_pragma import _normalize_pragma +from pre_commit_hooks.fix_encoding_pragma import fix_encoding_pragma +from pre_commit_hooks.fix_encoding_pragma import main + + +def test_integration_inserting_pragma(tmpdir): + path = tmpdir.join('foo.py') + path.write_binary(b'import httplib\n') + + assert main((str(path),)) == 1 + + assert path.read_binary() == ( + b'# -*- coding: utf-8 -*-\n' + b'import httplib\n' + ) + + +def test_integration_ok(tmpdir): + path = tmpdir.join('foo.py') + path.write_binary(b'# -*- coding: utf-8 -*-\nx = 1\n') + assert main((str(path),)) == 0 + + +def test_integration_remove(tmpdir): + path = tmpdir.join('foo.py') + path.write_binary(b'# -*- coding: utf-8 -*-\nx = 1\n') + + assert main((str(path), '--remove')) == 1 + + assert path.read_binary() == b'x = 1\n' + + +def test_integration_remove_ok(tmpdir): + path = tmpdir.join('foo.py') + path.write_binary(b'x = 1\n') + assert main((str(path), '--remove')) == 0 + + +@pytest.mark.parametrize( + 'input_str', + ( + b'', + ( + b'# -*- coding: utf-8 -*-\n' + b'x = 1\n' + ), + ( + b'#!/usr/bin/env python\n' + b'# -*- coding: utf-8 -*-\n' + b'foo = "bar"\n' + ), + ), +) +def test_ok_inputs(input_str): + bytesio = io.BytesIO(input_str) + assert fix_encoding_pragma(bytesio) == 0 + bytesio.seek(0) + assert bytesio.read() == input_str + + +@pytest.mark.parametrize( + ('input_str', 'output'), + ( + ( + b'import httplib\n', + b'# -*- coding: utf-8 -*-\n' + b'import httplib\n', + ), + ( + b'#!/usr/bin/env python\n' + b'x = 1\n', + b'#!/usr/bin/env python\n' + b'# -*- coding: utf-8 -*-\n' + b'x = 1\n', + ), + ( + b'#coding=utf-8\n' + b'x = 1\n', + b'# -*- coding: utf-8 -*-\n' + b'x = 1\n', + ), + ( + b'#!/usr/bin/env python\n' + b'#coding=utf8\n' + b'x = 1\n', + b'#!/usr/bin/env python\n' + b'# -*- coding: utf-8 -*-\n' + b'x = 1\n', + ), + # These should each get truncated + (b'#coding: utf-8\n', b''), + (b'# -*- coding: utf-8 -*-\n', b''), + (b'#!/usr/bin/env python\n', b''), + (b'#!/usr/bin/env python\n#coding: utf8\n', b''), + (b'#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n', b''), + ), +) +def test_not_ok_inputs(input_str, output): + bytesio = io.BytesIO(input_str) + assert fix_encoding_pragma(bytesio) == 1 + bytesio.seek(0) + assert bytesio.read() == output + + +def test_ok_input_alternate_pragma(): + input_s = b'# coding: utf-8\nx = 1\n' + bytesio = io.BytesIO(input_s) + ret = fix_encoding_pragma(bytesio, expected_pragma=b'# coding: utf-8') + assert ret == 0 + bytesio.seek(0) + assert bytesio.read() == input_s + + +def test_not_ok_input_alternate_pragma(): + bytesio = io.BytesIO(b'x = 1\n') + ret = fix_encoding_pragma(bytesio, expected_pragma=b'# coding: utf-8') + assert ret == 1 + bytesio.seek(0) + assert bytesio.read() == b'# coding: utf-8\nx = 1\n' + + +@pytest.mark.parametrize( + ('input_s', 'expected'), + ( + ('# coding: utf-8', b'# coding: utf-8'), + # trailing whitespace + ('# coding: utf-8\n', b'# coding: utf-8'), + ), +) +def test_normalize_pragma(input_s, expected): + assert _normalize_pragma(input_s) == expected + + +def test_integration_alternate_pragma(tmpdir, capsys): + f = tmpdir.join('f.py') + f.write('x = 1\n') + + pragma = '# coding: utf-8' + assert main((str(f), '--pragma', pragma)) == 1 + assert f.read() == '# coding: utf-8\nx = 1\n' + out, _ = capsys.readouterr() + assert out == f'Added `# coding: utf-8` to {str(f)}\n' + + +def test_crlf_ok(tmpdir): + f = tmpdir.join('f.py') + f.write_binary(b'# -*- coding: utf-8 -*-\r\nx = 1\r\n') + assert not main((str(f),)) + + +def test_crfl_adds(tmpdir): + f = tmpdir.join('f.py') + f.write_binary(b'x = 1\r\n') + assert main((str(f),)) + assert f.read_binary() == b'# -*- coding: utf-8 -*-\r\nx = 1\r\n' diff --git a/tests/forbid_new_submodules_test.py b/tests/forbid_new_submodules_test.py new file mode 100644 index 0000000..058a329 --- /dev/null +++ b/tests/forbid_new_submodules_test.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import os +import subprocess +from unittest import mock + +import pytest + +from pre_commit_hooks.forbid_new_submodules import main +from testing.util import git_commit + + +@pytest.fixture +def git_dir_with_git_dir(tmpdir): + with tmpdir.as_cwd(): + subprocess.check_call(('git', 'init', '.')) + git_commit('--allow-empty', '-m', 'init') + subprocess.check_call(('git', 'init', 'foo')) + git_commit('--allow-empty', '-m', 'init', cwd=str(tmpdir.join('foo'))) + yield + + +@pytest.mark.parametrize( + 'cmd', + ( + # Actually add the submodule + ('git', 'submodule', 'add', './foo'), + # Sneaky submodule add (that doesn't show up in .gitmodules) + ('git', 'add', 'foo'), + ), +) +def test_main_new_submodule(git_dir_with_git_dir, capsys, cmd): + subprocess.check_call(cmd) + assert main(('random_non-related_file',)) == 0 + assert main(('foo',)) == 1 + out, _ = capsys.readouterr() + assert out.startswith('foo: new submodule introduced\n') + + +def test_main_new_submodule_committed(git_dir_with_git_dir, capsys): + rev_parse_cmd = ('git', 'rev-parse', 'HEAD') + from_ref = subprocess.check_output(rev_parse_cmd).decode().strip() + subprocess.check_call(('git', 'submodule', 'add', './foo')) + git_commit('-m', 'new submodule') + to_ref = subprocess.check_output(rev_parse_cmd).decode().strip() + with mock.patch.dict( + os.environ, + {'PRE_COMMIT_FROM_REF': from_ref, 'PRE_COMMIT_TO_REF': to_ref}, + ): + assert main(('random_non-related_file',)) == 0 + assert main(('foo',)) == 1 + out, _ = capsys.readouterr() + assert out.startswith('foo: new submodule introduced\n') + + +def test_main_no_new_submodule(git_dir_with_git_dir): + open('test.py', 'a+').close() + subprocess.check_call(('git', 'add', 'test.py')) + assert main(('test.py',)) == 0 diff --git a/tests/mixed_line_ending_test.py b/tests/mixed_line_ending_test.py new file mode 100644 index 0000000..a7e7971 --- /dev/null +++ b/tests/mixed_line_ending_test.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.mixed_line_ending import main + + +@pytest.mark.parametrize( + ('input_s', 'output'), + ( + # mixed with majority of 'LF' + (b'foo\r\nbar\nbaz\n', b'foo\nbar\nbaz\n'), + # mixed with majority of 'CRLF' + (b'foo\r\nbar\nbaz\r\n', b'foo\r\nbar\r\nbaz\r\n'), + # mixed with majority of 'CR' + (b'foo\rbar\nbaz\r', b'foo\rbar\rbaz\r'), + # mixed with as much 'LF' as 'CRLF' + (b'foo\r\nbar\n', b'foo\nbar\n'), + # mixed with as much 'LF' as 'CR' + (b'foo\rbar\n', b'foo\nbar\n'), + # mixed with as much 'CRLF' as 'CR' + (b'foo\r\nbar\r', b'foo\r\nbar\r\n'), + # mixed with as much 'CRLF' as 'LF' as 'CR' + (b'foo\r\nbar\nbaz\r', b'foo\nbar\nbaz\n'), + ), +) +def test_mixed_line_ending_fixes_auto(input_s, output, tmpdir): + path = tmpdir.join('file.txt') + path.write_binary(input_s) + ret = main((str(path),)) + + assert ret == 1 + assert path.read_binary() == output + + +def test_non_mixed_no_newline_end_of_file(tmpdir): + path = tmpdir.join('f.txt') + path.write_binary(b'foo\nbar\nbaz') + assert not main((str(path),)) + # the hook *could* fix the end of the file, but leaves it alone + # this is mostly to document the current behaviour + assert path.read_binary() == b'foo\nbar\nbaz' + + +def test_mixed_no_newline_end_of_file(tmpdir): + path = tmpdir.join('f.txt') + path.write_binary(b'foo\r\nbar\nbaz') + assert main((str(path),)) + # the hook rewrites the end of the file, this is slightly inconsistent + # with the non-mixed case but I think this is the better behaviour + # this is mostly to document the current behaviour + assert path.read_binary() == b'foo\nbar\nbaz\n' + + +@pytest.mark.parametrize( + ('fix_option', 'input_s'), + ( + # All --fix=auto with uniform line endings should be ok + ('--fix=auto', b'foo\r\nbar\r\nbaz\r\n'), + ('--fix=auto', b'foo\rbar\rbaz\r'), + ('--fix=auto', b'foo\nbar\nbaz\n'), + # --fix=crlf with crlf endings + ('--fix=crlf', b'foo\r\nbar\r\nbaz\r\n'), + # --fix=lf with lf endings + ('--fix=lf', b'foo\nbar\nbaz\n'), + ), +) +def test_line_endings_ok(fix_option, input_s, tmpdir, capsys): + path = tmpdir.join('input.txt') + path.write_binary(input_s) + ret = main((fix_option, str(path))) + + assert ret == 0 + assert path.read_binary() == input_s + out, _ = capsys.readouterr() + assert out == '' + + +def test_no_fix_does_not_modify(tmpdir, capsys): + path = tmpdir.join('input.txt') + contents = b'foo\r\nbar\rbaz\nwomp\n' + path.write_binary(contents) + ret = main(('--fix=no', str(path))) + + assert ret == 1 + assert path.read_binary() == contents + out, _ = capsys.readouterr() + assert out == f'{path}: mixed line endings\n' + + +def test_fix_lf(tmpdir, capsys): + path = tmpdir.join('input.txt') + path.write_binary(b'foo\r\nbar\rbaz\n') + ret = main(('--fix=lf', str(path))) + + assert ret == 1 + assert path.read_binary() == b'foo\nbar\nbaz\n' + out, _ = capsys.readouterr() + assert out == f'{path}: fixed mixed line endings\n' + + +def test_fix_crlf(tmpdir): + path = tmpdir.join('input.txt') + path.write_binary(b'foo\r\nbar\rbaz\n') + ret = main(('--fix=crlf', str(path))) + + assert ret == 1 + assert path.read_binary() == b'foo\r\nbar\r\nbaz\r\n' + + +def test_fix_lf_all_crlf(tmpdir): + """Regression test for #239""" + path = tmpdir.join('input.txt') + path.write_binary(b'foo\r\nbar\r\n') + ret = main(('--fix=lf', str(path))) + + assert ret == 1 + assert path.read_binary() == b'foo\nbar\n' diff --git a/tests/no_commit_to_branch_test.py b/tests/no_commit_to_branch_test.py new file mode 100644 index 0000000..eaae5e6 --- /dev/null +++ b/tests/no_commit_to_branch_test.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.no_commit_to_branch import is_on_branch +from pre_commit_hooks.no_commit_to_branch import main +from pre_commit_hooks.util import cmd_output +from testing.util import git_commit + + +def test_other_branch(temp_git_dir): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', 'anotherbranch') + assert is_on_branch({'master'}) is False + + +def test_multi_branch(temp_git_dir): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', 'another/branch') + assert is_on_branch({'master'}) is False + + +def test_multi_branch_fail(temp_git_dir): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', 'another/branch') + assert is_on_branch({'another/branch'}) is True + + +def test_master_branch(temp_git_dir): + with temp_git_dir.as_cwd(): + assert is_on_branch({'master'}) is True + + +def test_main_branch_call(temp_git_dir): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', 'other') + assert main(('--branch', 'other')) == 1 + + +@pytest.mark.parametrize('branch_name', ('b1', 'b2')) +def test_forbid_multiple_branches(temp_git_dir, branch_name): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', branch_name) + assert main(('--branch', 'b1', '--branch', 'b2')) + + +def test_branch_pattern_fail(temp_git_dir): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', 'another/branch') + assert is_on_branch(set(), {'another/.*'}) is True + + +@pytest.mark.parametrize('branch_name', ('master', 'another/branch')) +def test_branch_pattern_multiple_branches_fail(temp_git_dir, branch_name): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', branch_name) + assert main(('--branch', 'master', '--pattern', 'another/.*')) + + +def test_main_default_call(temp_git_dir): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', 'anotherbranch') + assert main(()) == 0 + + +def test_not_on_a_branch(temp_git_dir): + with temp_git_dir.as_cwd(): + git_commit('--allow-empty', '-m1') + head = cmd_output('git', 'rev-parse', 'HEAD').strip() + cmd_output('git', 'checkout', head) + # we're not on a branch! + assert main(()) == 0 + + +@pytest.mark.parametrize('branch_name', ('master', 'main')) +def test_default_branch_names(temp_git_dir, branch_name): + with temp_git_dir.as_cwd(): + cmd_output('git', 'checkout', '-b', branch_name) + assert main(()) == 1 diff --git a/tests/pretty_format_json_test.py b/tests/pretty_format_json_test.py new file mode 100644 index 0000000..5ded724 --- /dev/null +++ b/tests/pretty_format_json_test.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import os +import shutil + +import pytest + +from pre_commit_hooks.pretty_format_json import main +from pre_commit_hooks.pretty_format_json import parse_num_to_int +from testing.util import get_resource_path + + +def test_parse_num_to_int(): + assert parse_num_to_int('0') == 0 + assert parse_num_to_int('2') == 2 + assert parse_num_to_int('\t') == '\t' + assert parse_num_to_int(' ') == ' ' + + +@pytest.mark.parametrize( + ('filename', 'expected_retval'), ( + ('not_pretty_formatted_json.json', 1), + ('unsorted_pretty_formatted_json.json', 1), + ('non_ascii_pretty_formatted_json.json', 1), + ('pretty_formatted_json.json', 0), + ), +) +def test_main(filename, expected_retval): + ret = main([get_resource_path(filename)]) + assert ret == expected_retval + + +@pytest.mark.parametrize( + ('filename', 'expected_retval'), ( + ('not_pretty_formatted_json.json', 1), + ('unsorted_pretty_formatted_json.json', 0), + ('non_ascii_pretty_formatted_json.json', 1), + ('pretty_formatted_json.json', 0), + ), +) +def test_unsorted_main(filename, expected_retval): + ret = main(['--no-sort-keys', get_resource_path(filename)]) + assert ret == expected_retval + + +@pytest.mark.parametrize( + ('filename', 'expected_retval'), ( + ('not_pretty_formatted_json.json', 1), + ('unsorted_pretty_formatted_json.json', 1), + ('non_ascii_pretty_formatted_json.json', 1), + ('pretty_formatted_json.json', 1), + ('tab_pretty_formatted_json.json', 0), + ), +) +def test_tab_main(filename, expected_retval): + ret = main(['--indent', '\t', get_resource_path(filename)]) + assert ret == expected_retval + + +def test_non_ascii_main(): + ret = main(( + '--no-ensure-ascii', + get_resource_path('non_ascii_pretty_formatted_json.json'), + )) + assert ret == 0 + + +def test_autofix_main(tmpdir): + srcfile = tmpdir.join('to_be_json_formatted.json') + shutil.copyfile( + get_resource_path('not_pretty_formatted_json.json'), + str(srcfile), + ) + + # now launch the autofix on that file + ret = main(['--autofix', str(srcfile)]) + # it should have formatted it + assert ret == 1 + + # file was formatted (shouldn't trigger linter again) + ret = main([str(srcfile)]) + assert ret == 0 + + +def test_orderfile_get_pretty_format(): + ret = main(( + '--top-keys=alist', get_resource_path('pretty_formatted_json.json'), + )) + assert ret == 0 + + +def test_not_orderfile_get_pretty_format(): + ret = main(( + '--top-keys=blah', get_resource_path('pretty_formatted_json.json'), + )) + assert ret == 1 + + +def test_top_sorted_get_pretty_format(): + ret = main(( + '--top-keys=01-alist,alist', get_resource_path('top_sorted_json.json'), + )) + assert ret == 0 + + +def test_badfile_main(): + ret = main([get_resource_path('ok_yaml.yaml')]) + assert ret == 1 + + +def test_diffing_output(capsys): + resource_path = get_resource_path('not_pretty_formatted_json.json') + expected_retval = 1 + a = os.path.join('a', resource_path) + b = os.path.join('b', resource_path) + expected_out = f'''\ +--- {a} ++++ {b} +@@ -1,6 +1,9 @@ + {{ +- "foo": +- "bar", +- "alist": [2, 34, 234], +- "blah": null ++ "alist": [ ++ 2, ++ 34, ++ 234 ++ ], ++ "blah": null, ++ "foo": "bar" + }} +''' + actual_retval = main([resource_path]) + actual_out, actual_err = capsys.readouterr() + + assert actual_retval == expected_retval + assert actual_out == expected_out + assert actual_err == '' diff --git a/tests/readme_test.py b/tests/readme_test.py new file mode 100644 index 0000000..038868d --- /dev/null +++ b/tests/readme_test.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pre_commit_hooks.check_yaml import yaml + + +def test_readme_contains_all_hooks(): + with open('README.md', encoding='UTF-8') as f: + readme_contents = f.read() + with open('.pre-commit-hooks.yaml', encoding='UTF-8') as f: + hooks = yaml.load(f) + for hook in hooks: + assert f'`{hook["id"]}`' in readme_contents diff --git a/tests/removed_test.py b/tests/removed_test.py new file mode 100644 index 0000000..cd66957 --- /dev/null +++ b/tests/removed_test.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.removed import main + + +def test_always_fails(): + with pytest.raises(SystemExit) as excinfo: + main(( + 'autopep8-wrapper', 'autopep8', + 'https://github.com/pre-commit/mirrors-autopep8', + '--foo', 'bar', + )) + msg, = excinfo.value.args + assert msg == ( + '`autopep8-wrapper` has been removed -- ' + 'use `autopep8` from https://github.com/pre-commit/mirrors-autopep8' + ) diff --git a/tests/requirements_txt_fixer_test.py b/tests/requirements_txt_fixer_test.py new file mode 100644 index 0000000..b725afa --- /dev/null +++ b/tests/requirements_txt_fixer_test.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.requirements_txt_fixer import FAIL +from pre_commit_hooks.requirements_txt_fixer import main +from pre_commit_hooks.requirements_txt_fixer import PASS +from pre_commit_hooks.requirements_txt_fixer import Requirement + + +@pytest.mark.parametrize( + ('input_s', 'expected_retval', 'output'), + ( + (b'', PASS, b''), + (b'\n', PASS, b'\n'), + (b'# intentionally empty\n', PASS, b'# intentionally empty\n'), + (b'foo\n# comment at end\n', PASS, b'foo\n# comment at end\n'), + (b'foo\nbar\n', FAIL, b'bar\nfoo\n'), + (b'bar\nfoo\n', PASS, b'bar\nfoo\n'), + (b'a\nc\nb\n', 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', + FAIL, + b'#comment2\nbar\n#comment1\nfoo\n', + ), + ( + b'#comment1\nbar\n#comment2\nfoo\n', + PASS, + b'#comment1\nbar\n#comment2\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'foo\n\t#comment with indent\nbar\n', + FAIL, + b'\t#comment with indent\nbar\nfoo\n', + ), + ( + b'bar\n\t#comment with indent\nfoo\n', + PASS, + b'bar\n\t#comment with indent\nfoo\n', + ), + (b'\nfoo\nbar\n', FAIL, b'bar\n\nfoo\n'), + (b'\nbar\nfoo\n', PASS, b'\nbar\nfoo\n'), + ( + b'pyramid-foo==1\npyramid>=2\n', + FAIL, + b'pyramid>=2\npyramid-foo==1\n', + ), + ( + b'a==1\n' + b'c>=1\n' + b'bbbb!=1\n' + b'c-a>=1;python_version>="3.6"\n' + b'e>=2\n' + b'd>2\n' + b'g<2\n' + b'f<=2\n', + FAIL, + b'a==1\n' + b'bbbb!=1\n' + b'c>=1\n' + b'c-a>=1;python_version>="3.6"\n' + b'd>2\n' + b'e>=2\n' + b'f<=2\n' + b'g<2\n', + ), + (b'ocflib\nDjango\nPyMySQL\n', FAIL, b'Django\nocflib\nPyMySQL\n'), + ( + b'-e git+ssh://git_url@tag#egg=ocflib\nDjango\nPyMySQL\n', + FAIL, + 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'foo\npkg-resources==0.0.0\nbar\n', FAIL, b'bar\nfoo\n'), + ( + b'git+ssh://git_url@tag#egg=ocflib\nDjango\nijk\n', + FAIL, + b'Django\nijk\ngit+ssh://git_url@tag#egg=ocflib\n', + ), + ( + b'b==1.0.0\n' + b'c=2.0.0 \\\n' + b' --hash=sha256:abcd\n' + b'a=3.0.0 \\\n' + b' --hash=sha256:a1b1c1d1', + FAIL, + b'a=3.0.0 \\\n' + b' --hash=sha256:a1b1c1d1\n' + b'b==1.0.0\n' + b'c=2.0.0 \\\n' + b' --hash=sha256:abcd\n', + ), + ( + b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n', + PASS, + b'a=2.0.0 \\\n --hash=sha256:abcd\nb==1.0.0\n', + ), + ), +) +def test_integration(input_s, expected_retval, output, tmpdir): + path = tmpdir.join('file.txt') + path.write_binary(input_s) + + output_retval = main([str(path)]) + + assert path.read_binary() == output + assert output_retval == expected_retval + + +def test_requirement_object(): + top_of_file = Requirement() + top_of_file.comments.append(b'#foo') + top_of_file.value = b'\n' + + requirement_foo = Requirement() + requirement_foo.value = b'foo' + + requirement_bar = Requirement() + requirement_bar.value = b'bar' + + # This may look redundant, but we need to test both foo.__lt__(bar) and + # bar.__lt__(foo) + assert requirement_foo > top_of_file + assert top_of_file < requirement_foo + assert requirement_foo > requirement_bar + assert requirement_bar < requirement_foo diff --git a/tests/sort_simple_yaml_test.py b/tests/sort_simple_yaml_test.py new file mode 100644 index 0000000..6cbda85 --- /dev/null +++ b/tests/sort_simple_yaml_test.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import os + +import pytest + +from pre_commit_hooks.sort_simple_yaml import first_key +from pre_commit_hooks.sort_simple_yaml import main +from pre_commit_hooks.sort_simple_yaml import parse_block +from pre_commit_hooks.sort_simple_yaml import parse_blocks +from pre_commit_hooks.sort_simple_yaml import sort + +RETVAL_GOOD = 0 +RETVAL_BAD = 1 +TEST_SORTS = [ + ( + ['c: true', '', 'b: 42', 'a: 19'], + ['b: 42', 'a: 19', '', 'c: true'], + RETVAL_BAD, + ), + + ( + ['# i am', '# a header', '', 'c: true', '', 'b: 42', 'a: 19'], + ['# i am', '# a header', '', 'b: 42', 'a: 19', '', 'c: true'], + RETVAL_BAD, + ), + + ( + ['# i am', '# a header', '', 'already: sorted', '', 'yup: i am'], + ['# i am', '# a header', '', 'already: sorted', '', 'yup: i am'], + RETVAL_GOOD, + ), + + ( + ['# i am', '# a header'], + ['# i am', '# a header'], + RETVAL_GOOD, + ), +] + + +@pytest.mark.parametrize('bad_lines,good_lines,retval', TEST_SORTS) +def test_integration_good_bad_lines(tmpdir, bad_lines, good_lines, retval): + file_path = os.path.join(str(tmpdir), 'foo.yaml') + + with open(file_path, 'w') as f: + f.write('\n'.join(bad_lines) + '\n') + + assert main([file_path]) == retval + + with open(file_path) as f: + assert [line.rstrip() for line in f.readlines()] == good_lines + + +def test_parse_header(): + lines = ['# some header', '# is here', '', 'this is not a header'] + assert parse_block(lines, header=True) == ['# some header', '# is here'] + assert lines == ['', 'this is not a header'] + + lines = ['this is not a header'] + assert parse_block(lines, header=True) == [] + assert lines == ['this is not a header'] + + +def test_parse_block(): + # a normal block + lines = ['a: 42', 'b: 17', '', 'c: 19'] + assert parse_block(lines) == ['a: 42', 'b: 17'] + assert lines == ['', 'c: 19'] + + # a block at the end + lines = ['c: 19'] + assert parse_block(lines) == ['c: 19'] + assert lines == [] + + # no block + lines = [] + assert parse_block(lines) == [] + assert lines == [] + + +def test_parse_blocks(): + # normal blocks + lines = ['a: 42', 'b: 17', '', 'c: 19'] + assert parse_blocks(lines) == [['a: 42', 'b: 17'], ['c: 19']] + assert lines == [] + + # a single block + lines = ['a: 42', 'b: 17'] + assert parse_blocks(lines) == [['a: 42', 'b: 17']] + assert lines == [] + + # no blocks + lines = [] + assert parse_blocks(lines) == [] + assert lines == [] + + +def test_first_key(): + # first line + lines = ['a: 42', 'b: 17', '', 'c: 19'] + assert first_key(lines) == 'a: 42' + + # second line + lines = ['# some comment', 'a: 42', 'b: 17', '', 'c: 19'] + assert first_key(lines) == 'a: 42' + + # second line with quotes + lines = ['# some comment', '"a": 42', 'b: 17', '', 'c: 19'] + assert first_key(lines) == 'a": 42' + + # no lines (not a real situation) + lines = [] + assert first_key(lines) == '' + + +@pytest.mark.parametrize('bad_lines,good_lines,_', TEST_SORTS) +def test_sort(bad_lines, good_lines, _): + assert sort(bad_lines) == good_lines diff --git a/tests/string_fixer_test.py b/tests/string_fixer_test.py new file mode 100644 index 0000000..8eb164c --- /dev/null +++ b/tests/string_fixer_test.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import textwrap + +import pytest + +from pre_commit_hooks.string_fixer import main + +TESTS = ( + # Base cases + ("''", "''", 0), + ('""', "''", 1), + (r'"\'"', r'"\'"', 0), + (r'"\""', r'"\""', 0), + (r"'\"\"'", r"'\"\"'", 0), + # String somewhere in the line + ('x = "foo"', "x = 'foo'", 1), + # Test escaped characters + (r'"\'"', r'"\'"', 0), + # Docstring + ('""" Foo """', '""" Foo """', 0), + ( + textwrap.dedent( + """ + x = " \\ + foo \\ + "\n + """, + ), + textwrap.dedent( + """ + x = ' \\ + foo \\ + '\n + """, + ), + 1, + ), + ('"foo""bar"', "'foo''bar'", 1), + pytest.param( + "f'hello{\"world\"}'", + "f'hello{\"world\"}'", + 0, + id='ignore nested fstrings', + ), +) + + +@pytest.mark.parametrize(('input_s', 'output', 'expected_retval'), TESTS) +def test_rewrite(input_s, output, expected_retval, tmpdir): + path = tmpdir.join('file.py') + path.write(input_s) + retval = main([str(path)]) + assert path.read() == output + assert retval == expected_retval + + +def test_rewrite_crlf(tmpdir): + f = tmpdir.join('f.py') + f.write_binary(b'"foo"\r\n"bar"\r\n') + assert main((str(f),)) + assert f.read_binary() == b"'foo'\r\n'bar'\r\n" diff --git a/tests/tests_should_end_in_test_test.py b/tests/tests_should_end_in_test_test.py new file mode 100644 index 0000000..2b5a0de --- /dev/null +++ b/tests/tests_should_end_in_test_test.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pre_commit_hooks.tests_should_end_in_test import main + + +def test_main_all_pass(): + ret = main(['foo_test.py', 'bar_test.py']) + assert ret == 0 + + +def test_main_one_fails(): + ret = main(['not_test_ending.py', 'foo_test.py']) + assert ret == 1 + + +def test_regex(): + assert main(('foo_test_py',)) == 1 + + +def test_main_django_all_pass(): + ret = main(( + '--django', 'tests.py', 'test_foo.py', 'test_bar.py', + 'tests/test_baz.py', + )) + assert ret == 0 + + +def test_main_django_one_fails(): + ret = main(['--django', 'not_test_ending.py', 'test_foo.py']) + assert ret == 1 + + +def test_validate_nested_files_django_one_fails(): + ret = main(['--django', 'tests/not_test_ending.py', 'test_foo.py']) + assert ret == 1 + + +def test_main_not_django_fails(): + ret = main(['foo_test.py', 'bar_test.py', 'test_baz.py']) + assert ret == 1 + + +def test_main_django_fails(): + ret = main(['--django', 'foo_test.py', 'test_bar.py', 'test_baz.py']) + assert ret == 1 + + +def test_main_pytest_test_first(): + assert main(['--pytest-test-first', 'test_foo.py']) == 0 + assert main(['--pytest-test-first', 'foo_test.py']) == 1 diff --git a/tests/trailing_whitespace_fixer_test.py b/tests/trailing_whitespace_fixer_test.py new file mode 100644 index 0000000..c07497a --- /dev/null +++ b/tests/trailing_whitespace_fixer_test.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.trailing_whitespace_fixer import main + + +@pytest.mark.parametrize( + ('input_s', 'expected'), + ( + ('foo \nbar \n', 'foo\nbar\n'), + ('bar\t\nbaz\t\n', 'bar\nbaz\n'), + ), +) +def test_fixes_trailing_whitespace(input_s, expected, tmpdir): + path = tmpdir.join('file.md') + path.write(input_s) + assert main((str(path),)) == 1 + assert path.read() == expected + + +def test_ok_no_newline_end_of_file(tmpdir): + filename = tmpdir.join('f') + filename.write_binary(b'foo\nbar') + ret = main((str(filename),)) + assert filename.read_binary() == b'foo\nbar' + assert ret == 0 + + +def test_ok_with_dos_line_endings(tmpdir): + filename = tmpdir.join('f') + filename.write_binary(b'foo\r\nbar\r\nbaz\r\n') + ret = main((str(filename),)) + assert filename.read_binary() == b'foo\r\nbar\r\nbaz\r\n' + assert ret == 0 + + +@pytest.mark.parametrize('ext', ('md', 'Md', '.md', '*')) +def test_fixes_markdown_files(tmpdir, ext): + path = tmpdir.join('test.md') + path.write( + 'foo \n' # leaves alone + 'bar \n' # less than two so it is removed + 'baz \n' # more than two so it becomes two spaces + '\t\n' # trailing tabs are stripped anyway + '\n ', # whitespace at the end of the file is removed + ) + ret = main((str(path), f'--markdown-linebreak-ext={ext}')) + assert ret == 1 + assert path.read() == ( + 'foo \n' + 'bar\n' + 'baz \n' + '\n' + '\n' + ) + + +@pytest.mark.parametrize('arg', ('--', 'a.b', 'a/b', '')) +def test_markdown_linebreak_ext_badopt(arg): + with pytest.raises(SystemExit) as excinfo: + main(['--markdown-linebreak-ext', arg]) + assert excinfo.value.code == 2 + + +def test_prints_warning_with_no_markdown_ext(capsys, tmpdir): + f = tmpdir.join('f').ensure() + assert main((str(f), '--no-markdown-linebreak-ext')) == 0 + out, _ = capsys.readouterr() + assert out == '--no-markdown-linebreak-ext now does nothing!\n' + + +def test_preserve_non_utf8_file(tmpdir): + non_utf8_bytes_content = b'<a>\xe9 \n</a>\n' + path = tmpdir.join('file.txt') + path.write_binary(non_utf8_bytes_content) + ret = main([str(path)]) + assert ret == 1 + assert path.size() == (len(non_utf8_bytes_content) - 1) + + +def test_custom_charset_change(tmpdir): + # strip spaces only, no tabs + path = tmpdir.join('file.txt') + path.write('\ta \t \n') + ret = main([str(path), '--chars', ' ']) + assert ret == 1 + assert path.read() == '\ta \t\n' + + +def test_custom_charset_no_change(tmpdir): + path = tmpdir.join('file.txt') + path.write('\ta \t\n') + ret = main([str(path), '--chars', ' ']) + assert ret == 0 + + +def test_markdown_with_custom_charset(tmpdir): + path = tmpdir.join('file.md') + path.write('\ta \t \n') + ret = main([str(path), '--chars', ' ', '--markdown-linebreak-ext', '*']) + assert ret == 1 + assert path.read() == '\ta \t \n' diff --git a/tests/util_test.py b/tests/util_test.py new file mode 100644 index 0000000..92473e5 --- /dev/null +++ b/tests/util_test.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import pytest + +from pre_commit_hooks.util import CalledProcessError +from pre_commit_hooks.util import cmd_output +from pre_commit_hooks.util import zsplit + + +def test_raises_on_error(): + with pytest.raises(CalledProcessError): + cmd_output('sh', '-c', 'exit 1') + + +def test_output(): + ret = cmd_output('sh', '-c', 'echo hi') + assert ret == 'hi\n' + + +@pytest.mark.parametrize('out', ('\0f1\0f2\0', '\0f1\0f2', 'f1\0f2\0')) +def test_check_zsplits_str_correctly(out): + assert zsplit(out) == ['f1', 'f2'] + + +@pytest.mark.parametrize('out', ('\0\0', '\0', '')) +def test_check_zsplit_returns_empty(out): + assert zsplit(out) == [] |