diff --git a/flake8/api/git.py b/flake8/api/git.py new file mode 100644 index 0000000..087e31b --- /dev/null +++ b/flake8/api/git.py @@ -0,0 +1,192 @@ +"""Module containing the main git hook interface and helpers. + +.. autofunction:: hook + +""" +import contextlib +import os +import shutil +import stat +import subprocess +import tempdir + +from flake8 import defaults +from flake8 import exceptions +from flake8.main import cli + +__all__ = ('hook', 'install') + + +def hook(lazy=False, strict=False): + """Execute Flake8 on the files in git's index. + + Determine which files are about to be committed and run Flake8 over them + to check for violations. + + :param bool lazy: + Find files not added to the index prior to committing. This is useful + if you frequently use ``git commit -a`` for example. This defaults to + False since it will otherwise include files not in the index. + :param bool strict: + If True, return the total number of errors/violations found by Flake8. + This will cause the hook to fail. + :returns: + Total number of errors found during the run. + :rtype: + int + """ + app = cli.Application() + with make_temporary_directory() as tempdir: + filepaths = list(copy_indexed_files_to(tempdir, lazy)) + app.initialize(filepaths) + app.run_checks() + + app.report_errors() + if strict: + return app.result_count + return 0 + + +def install(): + """Install the git hook script. + + This searches for the ``.git`` directory and will install an executable + pre-commit python script in the hooks sub-directory if one does not + already exist. + + :returns: + True if successful, False if the git directory doesn't exist. + :rtype: + bool + :raises: + flake8.exceptions.GitHookAlreadyExists + """ + git_directory = find_git_directory() + if git_directory is None or not os.path.exists(git_directory): + return False + + hooks_directory = os.path.join(git_directory, 'hooks') + if not os.path.exists(hooks_directory): + os.mkdir(hooks_directory) + + pre_commit_file = os.path.join(hooks_directory, 'hooks', 'pre-commit') + if os.path.exists(pre_commit_file): + raise exceptions.GitHookAlreadyExists( + 'File already exists', + path=pre_commit_file, + ) + + with open(pre_commit_file, 'w') as fd: + fd.write(_HOOK_TEMPLATE) + + # NOTE(sigmavirus24): The following sets: + # - read, write, and execute permissions for the owner + # - read permissions for people in the group + # - read permissions for other people + # The owner needs the file to be readable, writable, and executable + # so that git can actually execute it as a hook. + pre_commit_permissions = stat.S_IRWXU | stat.S_IRGRP | stat.IROTH + os.chmod(pre_commit_file, pre_commit_permissions) + return True + + +def find_git_directory(): + rev_parse = piped_process(['git', 'rev-parse', '--git-dir']) + + (stdout, _) = rev_parse.communicate() + stdout = to_text(stdout) + + if rev_parse.returncode == 0: + return stdout.strip() + return None + + +def copy_indexed_files_to(temporary_directory, lazy): + modified_files = find_modified_files(lazy) + for filename in modified_files: + contents = get_staged_contents_from(filename) + yield copy_file_to(temporary_directory, filename, contents) + + +def copy_file_to(destination_directory, filepath, contents): + directory, filename = os.path.split(os.path.abspath(filepath)) + temporary_directory = make_temporary_directory_from(destination_directory, + directory) + temporary_filepath = os.path.join(temporary_directory, filename) + with open(temporary_filepath, 'wb') as fd: + fd.write(contents) + return temporary_filepath + + +def make_temporary_directory_from(destination, directory): + prefix = os.path.commonprefix([directory, destination]) + common_directory_path = os.path.relpath(directory, start=prefix) + return os.path.join(destination, common_directory_path) + + +def find_modified_files(lazy): + diff_index = piped_process( + ['git', 'diff-index', '--cached', '--name-only', + '--diff-filter=ACMRTUXB', 'HEAD'], + ) + + (stdout, _) = diff_index.communicate() + stdout = to_text(stdout) + return stdout.splitlines() + + +def get_staged_contents_from(filename): + git_show = piped_process(['git', 'show', ':{0}'.format(filename)]) + (stdout, _) = git_show.communicate() + return stdout + + +@contextlib.contextmanager +def make_temporary_directory(): + temporary_directory = tempdir.mkdtemp() + yield temporary_directory + shutil.rmtree(temporary_directory, ignore_errors=True) + + +def to_text(string): + """Ensure that the string is text.""" + if callable(getattr(string, 'decode', None)): + return string.decode('utf-8') + return string + + +def piped_process(command): + return subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def git_config_for(parameter): + config = piped_process(['git', 'config', '--get', '--bool', parameter]) + (stdout, ) = config.communicate() + return to_text(stdout) + + +def config_for(parameter): + environment_variable = 'flake8_{0}'.format(parameter).upper() + git_variable = 'flake8.{0}'.format(parameter) + value = os.environ.get(environment_variable, git_config_for(git_variable)) + return value.lower() in defaults.TRUTHY_VALUES + + +_HOOK_TEMPLATE = """#!/usr/bin/env python +import os +import sys + +from flake8.api import git + +if __name__ == '__main__': + sys.exit( + git.hook( + strict=git.config_for('strict'), + lazy=git.config_for('lazy'), + ) + ) +""" diff --git a/flake8/defaults.py b/flake8/defaults.py index d484f4c..2007eed 100644 --- a/flake8/defaults.py +++ b/flake8/defaults.py @@ -4,5 +4,7 @@ EXCLUDE = '.svn,CVS,.bzr,.hg,.git,__pycache__,.tox' IGNORE = 'E121,E123,E126,E226,E24,E704,W503,W504' MAX_LINE_LENGTH = 79 +TRUTHY_VALUES = set(['true', '1', 't']) + # Other consants WHITESPACE = frozenset(' \t') diff --git a/flake8/exceptions.py b/flake8/exceptions.py index 18ee90c..c5f3c5a 100644 --- a/flake8/exceptions.py +++ b/flake8/exceptions.py @@ -41,3 +41,12 @@ class InvalidSyntax(Flake8Exception): # strerror attribute instead of a message attribute self.error_message = self.original_exception.strerror super(InvalidSyntax, self).__init__(*args, **kwargs) + + +class GitHookAlreadyExists(Flake8Exception): + """Exception raised when the git pre-commit hook file already exists.""" + + def __init__(self, *args, **kwargs): + """Initialize the path attribute.""" + self.path = kwargs.pop('path') + super(GitHookAlreadyExists, self).__init__(*args, **kwargs) diff --git a/flake8/main/cli.py b/flake8/main/cli.py index 93c4a14..23135c4 100644 --- a/flake8/main/cli.py +++ b/flake8/main/cli.py @@ -271,8 +271,11 @@ class Application(object): #: :attr:`option_manager` self.args = None #: The number of errors, warnings, and other messages after running - #: flake8 + #: flake8 and taking into account ignored errors and lines. self.result_count = 0 + #: The total number of errors before accounting for ignored errors and + #: lines. + self.total_result_count = 0 #: Whether the program is processing a diff or not self.running_against_diff = False @@ -406,11 +409,15 @@ class Application(object): results = self.file_checker_manager.report() self.total_result_count, self.result_count = results LOG.info('Found a total of %d results and reported %d', - self.total_result_count, - self.result_count) + self.total_result_count, self.result_count) - def _run(self, argv): - # type: (Union[NoneType, List[str]]) -> NoneType + def initialize(self, argv): + # type: () -> NoneType + """Initialize the application to be run. + + This finds the plugins, registers their options, and parses the + command-line arguments. + """ self.find_plugins() self.register_plugin_options() self.parse_configuration_and_cli(argv) @@ -418,6 +425,10 @@ class Application(object): self.make_notifier() self.make_guide() self.make_file_checker_manager() + + def _run(self, argv): + # type: (Union[NoneType, List[str]]) -> NoneType + self.initialize(argv) self.run_checks() self.report_errors()