From 7f9dde14f00900214443e396f0de7f22dbb42da6 Mon Sep 17 00:00:00 2001 From: William Ting Date: Fri, 29 Apr 2016 15:51:32 -0700 Subject: [PATCH 1/3] Open source check-indent to auto-yelpdent. --- README.md | 1 + hooks.yaml | 6 + pre_commit_hooks/auto_indent.py | 183 ++++++++++++++++++ .../file_with_acceptable_indentation | 62 ++++++ .../auto_indent/file_with_bad_indentation | 55 ++++++ .../auto_indent/file_with_partial_indentation | 61 ++++++ testing/resources/auto_indent/syntax_error | 4 + tests/auto_indent_test.py | 74 +++++++ tox.ini | 7 +- 9 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 pre_commit_hooks/auto_indent.py create mode 100644 testing/resources/auto_indent/file_with_acceptable_indentation create mode 100644 testing/resources/auto_indent/file_with_bad_indentation create mode 100644 testing/resources/auto_indent/file_with_partial_indentation create mode 100644 testing/resources/auto_indent/syntax_error create mode 100644 tests/auto_indent_test.py diff --git a/README.md b/README.md index 6c3a3ec..d0aaff6 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Add this to your `.pre-commit-config.yaml` ### Hooks available +- `auto-indent` - Checks and fixes indentation of function calls. - `autopep8-wrapper` - Runs autopep8 over python source. - Ignore PEP 8 violation types with `args: ['-i', '--ignore=E000,...']` or through configuration of the `[pep8]` section in setup.cfg / tox.ini. diff --git a/hooks.yaml b/hooks.yaml index 08f01c5..38497fa 100644 --- a/hooks.yaml +++ b/hooks.yaml @@ -1,3 +1,9 @@ +- id: auto-indent + name: Check and fix indentation + language: python + entry: auto-indent + description: "Checks and fixes indentation of things like parameters and arguments." + files: \.py$ - id: autopep8-wrapper name: autopep8 wrapper description: "Runs autopep8 over python source. If you configure additional arguments you'll want to at least include -i." diff --git a/pre_commit_hooks/auto_indent.py b/pre_commit_hooks/auto_indent.py new file mode 100644 index 0000000..ddfacfa --- /dev/null +++ b/pre_commit_hooks/auto_indent.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from __future__ import print_function + +import argparse +import ast +import io +import re +import textwrap +import traceback +from operator import itemgetter + + +RETURN_CODE = dict( + no_change=0, + fixed_file=1, + syntax_error=2, +) + + +class IndentNodeVisitor(ast.NodeVisitor): + + def __init__(self): + self.lines = [] + + def visit_Call(self, node): + args = list(node.args) + + if node.keywords: + args.extend([keyword.value for keyword in node.keywords]) + + if node.starargs: + args.append(node.starargs) + + if node.kwargs: + args.append(node.kwargs) + + try: + if args and args[0].lineno == node.lineno: + for arg in args: + if arg.lineno != node.lineno: + self.lines.append({ + 'line': node.lineno, + 'nextline': arg.lineno, + 'column': args[0].col_offset, + }) + break + except AttributeError: + # Python 3.3 is not supported because line numbers were + # removed from arg objects (compared to Python 2.7 and Python 3.4): + # https://docs.python.org/3.3/library/ast.html + pass + finally: + self.generic_visit(node) + + def visit_ClassDef(self, node): + if node.bases and node.bases[0].lineno != node.lineno: + self.generic_visit(node) + return + + for base in node.bases: + if base.lineno != node.lineno: + self.lines.append({ + 'line': node.lineno, + 'nextline': base.lineno, + 'column': node.bases[0].col_offset, + }) + + break + + self.generic_visit(node) + + def visit_FunctionDef(self, node): + try: + if node.args.args and node.args.args[0].lineno != node.lineno: + self.generic_visit(node) + return + + for arg in node.args.args: + if arg.lineno != node.lineno: + self.lines.append({ + 'line': node.lineno, + 'nextline': arg.lineno, + 'column': node.args.args[0].col_offset, + }) + + break + except AttributeError: + # Python 3.3 is not supported because line numbers were + # removed from arg objects (compared to Python 2.7 and Python 3.4): + # https://docs.python.org/3.3/library/ast.html + pass + finally: + self.generic_visit(node) + + +def check_files(files): + rc = RETURN_CODE['no_change'] + + for fpath in files: + with open(fpath) as f: + source = f.read() + + try: + tree = ast.parse(source, fpath) + except SyntaxError: + traceback.print_exc() + return RETURN_CODE['syntax_error'] + + visitor = IndentNodeVisitor() + visitor.visit(tree) + + if visitor.lines: + rc = RETURN_CODE['fixed_file'] + print('Fixing ' + fpath) + fix_lines( + file_path=fpath, + source_lines=source.split('\n'), + line_data=visitor.lines, + ) + + return rc + + +def fix_lines(file_path, source_lines, line_data): + line_data = sorted(line_data, key=itemgetter('line')) + last_lineno = None + same_line_adjustment = 0 + + for i, data in enumerate(line_data): + adjustment = 0 + lineno = data['line'] + i - 1 + + if data['line'] == last_lineno: + adjustment = same_line_adjustment + + start = 1 + source_lines[lineno].rfind( + '(', + 0, + data['column'] + adjustment, + ) + + nextline = source_lines[data['nextline'] + i - 1] + indent = re.search('[ \t]*', nextline).group() + new_line = indent + source_lines[lineno][start:] + + source_lines[lineno] = source_lines[lineno][:start] + source_lines.insert(lineno + 1, new_line) + + with io.open(file_path, 'w') as f: + f.write(u'\n'.join(source_lines)) + + last_lineno = data['line'] + same_line_adjustment = len(indent) - start + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=textwrap.dedent( + """ + Checks that parameters and arguments to functions, classes, + and function calls are either all on the same line as the + corresponding function or class or that none are. + + If it finds any violations, the offending parameters or arguments + will automatically be moved to a new line. + + Return codes are: + + 0: no file change + 1: file fixed + 2: Python syntax error + """ + ) + ) + parser.add_argument('filenames', nargs='*') + args = parser.parse_args(argv) + + return check_files(args.filenames) + + +if __name__ == '__main__': + exit(main()) diff --git a/testing/resources/auto_indent/file_with_acceptable_indentation b/testing/resources/auto_indent/file_with_acceptable_indentation new file mode 100644 index 0000000..8023e04 --- /dev/null +++ b/testing/resources/auto_indent/file_with_acceptable_indentation @@ -0,0 +1,62 @@ +# bad +def foo( + a, + b, + c, + *args, + **kwargs +): + pass + +# okay +def bar(): + pass + +# bad +_ = foo( + 1, + 2, + 3, + 4, + e=5, + *[], + **{} +) + +# okay +_ = bar() + +# okay +_ = foo(bar, baz, womp) + +# bad +class baz( + object, + object, +): + pass + +# okay +class qux: + pass + +# bad +_ = foo( + foo( + 1 + foo( + 1, # trickier + 2, + 3, + ), + 4, + 5, + ), + 6, + 7, +) + +# bad +_ = '(wow)'.foo( + no, + way, +) diff --git a/testing/resources/auto_indent/file_with_bad_indentation b/testing/resources/auto_indent/file_with_bad_indentation new file mode 100644 index 0000000..e446c2e --- /dev/null +++ b/testing/resources/auto_indent/file_with_bad_indentation @@ -0,0 +1,55 @@ +# bad +def foo(a, + b, + c, + *args, + **kwargs +): + pass + +# okay +def bar(): + pass + +# bad +_ = foo(1, + 2, + 3, + 4, + e=5, + *[], + **{} +) + +# okay +_ = bar() + +# okay +_ = foo(bar, baz, womp) + +# bad +class baz(object, + object, +): + pass + +# okay +class qux: + pass + +# bad +_ = foo(foo(1 + foo(1, # trickier + 2, + 3, + ), + 4, + 5, + ), + 6, + 7, +) + +# bad +_ = '(wow)'.foo(no, + way, +) diff --git a/testing/resources/auto_indent/file_with_partial_indentation b/testing/resources/auto_indent/file_with_partial_indentation new file mode 100644 index 0000000..8d12d82 --- /dev/null +++ b/testing/resources/auto_indent/file_with_partial_indentation @@ -0,0 +1,61 @@ +# bad +def foo(a, + b, + c, + *args, + **kwargs +): + pass + +# okay +def bar(): + pass + +# bad +_ = foo( + 1, + 2, + 3, + 4, + e=5, + *[], + **{} +) + +# okay +_ = bar() + +# okay +_ = foo(bar, baz, womp) + +# bad +class baz( + object, + object, +): + pass + +# okay +class qux: + pass + +# bad +_ = foo( + foo( + 1 + foo( + 1, # trickier + 2, + 3, + ), + 4, + 5, + ), + 6, + 7, +) + +# bad +_ = '(wow)'.foo( + no, + way, +) diff --git a/testing/resources/auto_indent/syntax_error b/testing/resources/auto_indent/syntax_error new file mode 100644 index 0000000..84a3a5a --- /dev/null +++ b/testing/resources/auto_indent/syntax_error @@ -0,0 +1,4 @@ +def foo(a, + b, +) + pass diff --git a/tests/auto_indent_test.py b/tests/auto_indent_test.py new file mode 100644 index 0000000..2461433 --- /dev/null +++ b/tests/auto_indent_test.py @@ -0,0 +1,74 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from __future__ import print_function + +import filecmp +import os +import sys +from shutil import copyfile + +import pytest + +from pre_commit_hooks.auto_indent import main +from pre_commit_hooks.auto_indent import RETURN_CODE +from testing.util import get_resource_path + + +def _has_ast_arg_lineno_support(): + """Checks if AST has all the info necessary to auto indent. + + Some Python implementations removed line numbers from AST arg objects and + as a result we can't automatically format function calls. + """ + if sys.version_info.major == 3 and sys.version_info.minor == 3: + return False + elif sys.version_info.major == 3 and sys.version_info.minor == 2: + return False + else: + return True + + +@pytest.mark.parametrize( + ('infile', 'expected_rc'), + ( + ('file_with_acceptable_indentation', RETURN_CODE['no_change']), + ('syntax_error', RETURN_CODE['syntax_error']), + ) +) +def test_check_indentation(infile, expected_rc): + in_path = get_resource_path(os.path.join('auto_indent', infile)) + assert main([in_path]) == expected_rc + + +@pytest.mark.skipif(not _has_ast_arg_lineno_support(), reason='See docstring.') +def test_fix_bad_indentation(tmpdir): + in_path = get_resource_path(os.path.join( + 'auto_indent', + 'file_with_bad_indentation' + )) + expected_out_path = get_resource_path(os.path.join( + 'auto_indent', + 'file_with_acceptable_indentation' + )) + tmp_path = tmpdir.join('auto_indent').strpath + copyfile(in_path, tmp_path) + + assert main([tmp_path]) == RETURN_CODE['fixed_file'] + assert filecmp.cmp(tmp_path, expected_out_path) + + +@pytest.mark.skipif(_has_ast_arg_lineno_support(), reason='See docstring.') +def test_partial_fix_bad_indentation(tmpdir): + in_path = get_resource_path(os.path.join( + 'auto_indent', + 'file_with_bad_indentation' + )) + expected_out_path = get_resource_path(os.path.join( + 'auto_indent', + 'file_with_partial_indentation' + )) + tmp_path = tmpdir.join('auto_indent').strpath + copyfile(in_path, tmp_path) + + assert main([tmp_path]) == RETURN_CODE['fixed_file'] + assert filecmp.cmp(tmp_path, expected_out_path) diff --git a/tox.ini b/tox.ini index a9b17c0..5e43e1a 100644 --- a/tox.ini +++ b/tox.ini @@ -4,11 +4,14 @@ project = pre_commit_hooks envlist = py27,py33,py34,pypy,pypy3 [testenv] -deps = -rrequirements-dev.txt +deps = + -rrequirements-dev.txt + ipdb + ipython passenv = HOME HOMEPATH PROGRAMDATA commands = coverage erase - coverage run -m pytest {posargs:tests} + coverage run -m pytest -s -rxs -vv {posargs:tests} coverage report --show-missing --fail-under 100 pre-commit install -f --install-hooks pre-commit run --all-files From 2443de265578735faba441485be711e07b0fe2f2 Mon Sep 17 00:00:00 2001 From: William Ting Date: Sat, 30 Apr 2016 23:05:53 -0700 Subject: [PATCH 2/3] Add pytest options and ignore test source code coverage. --- tox.ini | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 5e43e1a..86543fb 100644 --- a/tox.ini +++ b/tox.ini @@ -6,12 +6,10 @@ envlist = py27,py33,py34,pypy,pypy3 [testenv] deps = -rrequirements-dev.txt - ipdb - ipython passenv = HOME HOMEPATH PROGRAMDATA commands = coverage erase - coverage run -m pytest -s -rxs -vv {posargs:tests} + coverage run --source=pre_commit_hooks/ -m pytest -rxs --durations 10 {posargs:tests} coverage report --show-missing --fail-under 100 pre-commit install -f --install-hooks pre-commit run --all-files From 5ba9965103b8958a9e61aad89bcc0228ee7e4a12 Mon Sep 17 00:00:00 2001 From: William Ting Date: Sat, 30 Apr 2016 23:15:55 -0700 Subject: [PATCH 3/3] Fail build if test coverage drops is <99% instead of <100%. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 86543fb..f749447 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ passenv = HOME HOMEPATH PROGRAMDATA commands = coverage erase coverage run --source=pre_commit_hooks/ -m pytest -rxs --durations 10 {posargs:tests} - coverage report --show-missing --fail-under 100 + coverage report --show-missing --fail-under 99 pre-commit install -f --install-hooks pre-commit run --all-files pylint {[tox]project} testing tests setup.py