"""Module containing the main git hook interface and helpers. .. autofunction:: hook .. autofunction:: install """ import contextlib import os import os.path import shutil import stat import subprocess import sys import tempfile from flake8 import defaults from flake8 import exceptions __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 """ # NOTE(sigmavirus24): Delay import of application until we need it. from flake8.main import application app = application.Application() with make_temporary_directory() as tempdir: filepaths = list(copy_indexed_files_to(tempdir, lazy)) app.initialize(["."]) app.options.exclude = update_excludes(app.options.exclude, tempdir) app.options._running_from_vcs = True # Apparently there are times when there are no files to check (e.g., # when amending a commit). In those cases, let's not try to run checks # against nothing. if filepaths: app.run_checks(filepaths) # If there were files to check, update their paths and report the errors if filepaths: update_paths(app.file_checker_manager, tempdir) 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. It will also print a message to stdout about how to configure the hook. :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.abspath( os.path.join(hooks_directory, "pre-commit") ) if os.path.exists(pre_commit_file): raise exceptions.GitHookAlreadyExists( "File already exists", path=pre_commit_file ) executable = get_executable() with open(pre_commit_file, "w") as fd: fd.write(_HOOK_TEMPLATE.format(executable=executable)) # 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.S_IROTH os.chmod(pre_commit_file, pre_commit_permissions) print("git pre-commit hook installed, for configuration options see") print("http://flake8.pycqa.org/en/latest/user/using-hooks.html") return True def get_executable(): if sys.executable is not None: return sys.executable return "/usr/bin/env python" 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): # some plugins (e.g. flake8-isort) need these files to run their checks setup_cfgs = find_setup_cfgs(lazy) for filename in setup_cfgs: contents = get_staged_contents_from(filename) copy_file_to(temporary_directory, filename, contents) 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 ) if not os.path.exists(temporary_directory): os.makedirs(temporary_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_cmd = [ "git", "diff-index", "--cached", "--name-only", "--diff-filter=ACMRTUXB", "HEAD", ] if lazy: diff_index_cmd.remove("--cached") diff_index = piped_process(diff_index_cmd) (stdout, _) = diff_index.communicate() stdout = to_text(stdout) return stdout.splitlines() def find_setup_cfgs(lazy): setup_cfg_cmd = ["git", "ls-files", "--cached", "*setup.cfg"] if lazy: setup_cfg_cmd.remove("--cached") extra_files = piped_process(setup_cfg_cmd) (stdout, _) = extra_files.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 = tempfile.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).strip() 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 def update_excludes(exclude_list, temporary_directory_path): return [ (temporary_directory_path + pattern) if os.path.isabs(pattern) else pattern for pattern in exclude_list ] def update_paths(checker_manager, temp_prefix): temp_prefix_length = len(temp_prefix) for checker in checker_manager.checkers: filename = checker.display_name if filename.startswith(temp_prefix): checker.display_name = os.path.relpath( filename[temp_prefix_length:] ) _HOOK_TEMPLATE = """#!{executable} import sys from flake8.main import git if __name__ == '__main__': sys.exit( git.hook( strict=git.config_for('strict'), lazy=git.config_for('lazy'), ) ) """