#!/usr/bin/env python """Simple, on demand git checker""" import argparse import errno import json import os import pathlib import subprocess import sys import git class InvalidGitRepositoryError(Exception): """ InvalidGitRepositoryError Raised when invalid repository path is provided """ class ValidationError(Exception): """ValidationError""" def eprint( *value: object, sep: str | None = ' ', end: str | None = '\n' ) -> None: """stdderr print wrapper""" print(*value, sep=sep, end=end, file=sys.stderr, flush=True) class CLIHandler: """Helper class used for handleing the growing amount of arguments""" def __init__( self, args: argparse.Namespace, config_dict: dict[str, list] ) -> None: """ :param args: The parsed argparse arguments sent by the caller :type args: argparse.Namespace :param config_dict: The config file structure :type config_dict: dict """ self._args = args self._config_dict = config_dict self._py_checker = PyChecker( tests=self._config_dict['tests'], skip=args.skip.split(',') if args.skip else None, verbose=args.verbose, ) self._repository_path = pathlib.Path(args.repo_path).absolute() def handle_args(self) -> None: """Runs the handler""" if self._args.python_file: self._py_checker.validate_python_file(self._args.python_file) return repository_handler = GitRepositoryHandler( self._repository_path, target_extensions=self._config_dict['target_extensions'], ignore_dirs=self._config_dict['ignore_dirs'], ) if self._args.all_files: selected_files = repository_handler.get_all_target_files() else: selected_files = repository_handler.get_new_and_modified() if not selected_files: print('No files selected') return for target_file in selected_files: if self._args.list_only: print(target_file) continue if self._args.verbose: print(f'Validating {target_file}:') self._py_checker.validate_python_file(target_file) return class GitRepositoryHandler: """Handler for the git repository logic""" def __init__( self, repository_path: pathlib.Path, target_extensions: list[str], ignore_dirs: list[str] | None = None, ) -> None: """ Constructs handler for a specific repository path It takes `target_extensions` as an input in order to define a file as a target. Example ['.py'], ['.cpp', '.cc'] `ignore_dirs` is used to define a list of directory names to ignore (not traverse in) by `get_all_target_files`. :param repository_path: Absolute path of the git repository :type repository_path: pathlib.Path :param target_extensions: A list of file extensions (f.i. ['.py']) :type target_extensions: list :param ignore_dirs: A list of directory names to ignore when :type ignore_dirs: list or None """ self._repository_path = repository_path self._target_extensions = target_extensions self._ignore_dirs: list[str] = ( ignore_dirs if ignore_dirs is not None else [] ) if not self._repository_path.is_dir(): raise InvalidGitRepositoryError( 'Specified path is not a directory' ) try: self._git_repository = git.Repo(self._repository_path) except git.InvalidGitRepositoryError as err: raise InvalidGitRepositoryError( 'Specified path is pointing to a directory that is not a ' 'Git repository' ) from err def get_all_target_files(self) -> list[pathlib.Path]: """ Fetches all .py-files :return: A list containing the paths of all .py files :rtype: list """ all_files_list = [] for root, _, files in os.walk(self._repository_path): if any(idir in root for idir in self._ignore_dirs): continue all_files_list.extend( pathlib.Path(root) / f for f in files if pathlib.Path(f).suffix.lower() in self._target_extensions ) all_files_list.sort() return all_files_list def get_new_and_modified(self) -> list[pathlib.Path]: """ Fetches new and modified .py-files using git :return: A list of new and modified file paths :rtype: list """ modified_files = { self._repository_path / pathlib.Path(mfile.a_path) for mfile in self._git_repository.index.diff(None) if mfile.a_path is not None and mfile.a_path.lower().endswith('.py') } staged_files = { self._repository_path / pathlib.Path(sfile.a_path) for sfile in self._git_repository.index.diff('HEAD') if sfile.a_path is not None and sfile.a_path.lower().endswith('.py') } untracked_files = { self._repository_path / pathlib.Path(ufile) for ufile in self._git_repository.untracked_files if ufile is not None and ufile.lower().endswith('.py') } return sorted(modified_files | staged_files | untracked_files) class PyChecker: """Implements the checker logic""" def __init__( self, tests: list[dict], skip: list[str] | None = None, *, verbose: bool = False, ) -> None: """ :param tests: A list of test dicts :type tests: list :param skip: A list of test names to skip (default: None) :type skip: list or None :param verbose: Turn on verbosity (default: False) :type verbose: bool """ self._tests = tests self._skip = skip if skip is not None else [] self._verbose = verbose def validate_python_file(self, pfile: pathlib.Path) -> None: """ Validates a single .py file against tests :param pfile: Python file (absolute path) :type pfile: pathlib.Path :return: False if validation fails, False otherwise :rtype: bool """ for test in self._tests: if test['name'] in self._skip: if self._verbose: print(f'{pfile} <- {test["name"]} (skipping)') continue try: params = [ param.replace('%p', str(pfile)) for param in test['params'] ] if self._verbose: print(f'{pfile} <- {test["name"]} ({" ".join(params)})') subprocess.run(params, check=test['check']) except subprocess.CalledProcessError as err: raise ValidationError( f'{pfile}: {test["name"]} validation failed' ) from err def main(inargs: list[str] | None = None) -> None: """Main entry point""" parser = argparse.ArgumentParser( description='The following options are available' ) parser.add_argument( 'repo_path', metavar='', nargs='?', default=pathlib.Path.cwd(), help='Git repo path (default: cwd)', ) parser.add_argument( '-a', '--all-files', action='store_true', dest='all_files', help='Check all files, not only the modified ones', ) parser.add_argument( '-c', '--config', metavar='', type=pathlib.Path, default=pathlib.Path('~/.pygitchecker.json').expanduser(), dest='config_file', help='Config file (default: ~/.pygitchecker.json)', ) parser.add_argument( '-F', '--file', metavar='', type=str, default='', dest='python_file', help='Validate a particular .py file', ) parser.add_argument( '-l', '--list-only', action='store_true', dest='list_only', help='Only lists the target files without performing any checks', ) parser.add_argument( '-s', '--skip', metavar='', type=str, default='', dest='skip', help='Skip one or more tests', ) parser.add_argument( '-v', '--verbose', action='store_true', dest='verbose', help='Verbosity', ) args = parser.parse_args(inargs) try: with pathlib.Path(args.config_file).open(encoding='utf-8') as fp: config_dict = json.load(fp) except Exception as exp: eprint(f'Unable to parse {args.config_file}: {exp}') sys.exit(errno.EIO) try: cli_handler = CLIHandler(args, config_dict) cli_handler.handle_args() sys.exit(0) except KeyboardInterrupt: print('KeyboardInterrupt\n') sys.exit(0) except InvalidGitRepositoryError as err: eprint(f'Invalid Git repository: {err}') sys.exit(1) except ValidationError as err: if args.verbose: eprint(f'Validation error: {err}') sys.exit(0) if __name__ == '__main__': main()