From c2bc8ec8229834c468cf9c2599a0c59bfc437c14 Mon Sep 17 00:00:00 2001 From: Simeon Simeonov Date: Wed, 22 Apr 2026 22:49:42 +0200 Subject: Restructure the code and remove functionality that doesn't belong in pygitchecker.py (-C, -f, -m, -r) --- pygitchecker.py | 388 +++++++++++++++++++++++++++++++------------------------- 1 file changed, 215 insertions(+), 173 deletions(-) (limited to 'pygitchecker.py') diff --git a/pygitchecker.py b/pygitchecker.py index 2e11542..aad9a05 100755 --- a/pygitchecker.py +++ b/pygitchecker.py @@ -1,155 +1,247 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- """Simple, on demand git checker""" + import argparse import errno -import io 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(*arg, **kwargs): +def eprint( + *value: object, sep: str | None = ' ', end: str | None = '\n' +) -> None: """stdderr print wrapper""" - print(*arg, file=sys.stderr, flush=True, **kwargs) - + print(*value, sep=sep, end=end, file=sys.stderr, flush=True) -def is_git_repo(abspath): - """True of abspath points to a git repo, False otherwise""" - if not os.path.isdir(abspath): - return False - try: - git.Repo(abspath) - return True - except git.InvalidGitRepositoryError: - return False +class CLIHandler: + """Helper class used for handleing the growing amount of arguments""" -def modifications_exist(abspath): - """ - True if modifications exist for git repo representing abspath - False if no modifications exist + 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 - git.InvalidGitRepositoryError raised if abspath points to illegal repo - """ - repo = git.Repo(abspath) - return bool(repo.is_dirty() or repo.index.diff(None)) + :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 -def print_modification_status(abspath, status, args): - """Prints the modification status for the given abspath""" - if status: - if args.verbose: - print(f'{abspath} -> modified') + 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: - print(abspath) - return status - if args.verbose: - print(f'{abspath} -> unchanged') - return status - - -def fetch_git_repos(abspath: str): - """Returns a list of git repo paths under abspath""" - repo_list = [] - for root, dirs, _ in os.walk(abspath): - if '/.git' in root: - continue - if '.git' in dirs: - try: - git.Repo(root) - repo_list.append(root) - except git.InvalidGitRepositoryError: + 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 - return repo_list + if self._args.verbose: + print(f'Validating {target_file}:') + self._py_checker.validate_python_file(target_file) -def fetch_git_files(args): - """Fetches new and modified .py-files using git""" - try: - repo = git.Repo(args.repo_path) + 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 = { - mfile.a_path - for mfile in repo.index.diff(None) - if mfile.a_path.lower().endswith('.py') + 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 = { - sfile.a_path - for sfile in repo.index.diff('HEAD') - if sfile.a_path.lower().endswith('.py') + 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') } - untracked_files = set( - filter(lambda f: f.lower().endswith('.py'), repo.untracked_files) - ) - return list(modified_files | staged_files | untracked_files) - except git.InvalidGitRepositoryError: - eprint(f'{args.repo_path} is not a valid git repository') - sys.exit(1) + return sorted(modified_files | staged_files | untracked_files) -def fetch_all_files(abspath): - """Fetches all .py-files""" - all_files_list = [] - for root, _, files in os.walk(abspath): - for f in files: - if os.path.splitext(f)[1].lower() == '.py': - all_files_list.append(os.path.join(abspath, root, f)) - return all_files_list +class PyChecker: + """Implements the checker logic""" -def validate_python_file(pfile, tests, args): - """ - Validates a single .py file against tests + 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 tests: A sequence of test dicts - :type tests: collections.Sequence + :param skip: A list of test names to skip (default: None) + :type skip: list or None - :param pfile: Python file (abs. path) - :type pfile: str + :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 - :param args: Argparse Namespace-object - :type args: argparse.Namespace + def validate_python_file(self, pfile: pathlib.Path) -> None: + """ + Validates a single .py file against tests - :return: False if validation fails, False otherwise - :rtype: bool - """ - skipped = [] - if args.skip: - skipped = args.skip.split(',') - for test in tests: - if test['name'] in skipped: - if args.verbose: - print(f'{pfile} <- {test["name"]} (skipping)') - continue - if args.verbose: - print(f'{pfile} <- {test["name"]}') - try: - params = [param.replace('%p', pfile) for param in test['params']] - subprocess.run(params, check=test['check']) - except subprocess.CalledProcessError: - if not args.continue_checks: - if args.verbose: - eprint(f'{pfile}: {test["name"]} validation failed') - sys.exit(0) + :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=None): + +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='REPO', + metavar='', nargs='?', - default=os.getcwd(), + default=pathlib.Path.cwd(), help='Git repo path (default: cwd)', ) parser.add_argument( @@ -159,19 +251,12 @@ def main(inargs=None): dest='all_files', help='Check all files, not only the modified ones', ) - parser.add_argument( - '-C', - '--continue-checks', - action='store_true', - dest='continue_checks', - help='Continue with the checks if validation fails', - ) parser.add_argument( '-c', '--config', metavar='', - type=str, - default=os.path.expanduser('~/.pygitchecker.json'), + type=pathlib.Path, + default=pathlib.Path('~/.pygitchecker.json').expanduser(), dest='config_file', help='Config file (default: ~/.pygitchecker.json)', ) @@ -184,13 +269,6 @@ def main(inargs=None): dest='python_file', help='Validate a particular .py file', ) - parser.add_argument( - '-f', - '--full', - action='store_true', - dest='full_search', - help='Consider repos that contain untracked files (used with -m)', - ) parser.add_argument( '-l', '--list-only', @@ -198,20 +276,6 @@ def main(inargs=None): dest='list_only', help='Only lists the target files without performing any checks', ) - parser.add_argument( - '-m', - '--modifications-only', - action='store_true', - dest='mod_only', - help='Only check for git modifications', - ) - parser.add_argument( - '-r', - '--recursive', - action='store_true', - dest='recursive', - help='Look for several repos recursively', - ) parser.add_argument( '-s', '--skip', @@ -228,52 +292,30 @@ def main(inargs=None): dest='verbose', help='Verbosity', ) + args = parser.parse_args(inargs) + try: - with io.open(args.config_file, 'r', encoding='utf-8') as fp: + with pathlib.Path(args.config_file).open(encoding='utf-8') as fp: config_dict = json.load(fp) - except Exception as e: - eprint(f'Unable to parse {args.config_file}: {e}') + except Exception as exp: + eprint(f'Unable to parse {args.config_file}: {exp}') sys.exit(errno.EIO) - if args.python_file: - validate_python_file(args.python_file, config_dict.get('tests'), args) - sys.exit(0) - abs_repo_path = os.path.abspath(os.path.expanduser(args.repo_path)) - if args.mod_only: - if not args.recursive: - if not is_git_repo(abs_repo_path): - eprint(f'{abs_repo_path} is not a valid git repository') - sys.exit(1) - print_modification_status( - abs_repo_path, modifications_exist(abs_repo_path), args - ) - sys.exit(0) - # resursive - repo_list = fetch_git_repos(abs_repo_path) - if not repo_list: - print('No git-repos found') - sys.exit(0) - repo_list.sort() - for repo_path in repo_list: - print_modification_status( - repo_path, modifications_exist(repo_path), args - ) + + try: + cli_handler = CLIHandler(args, config_dict) + cli_handler.handle_args() sys.exit(0) - if args.all_files: - selected_files = fetch_all_files(abs_repo_path) - else: - selected_files = fetch_git_files(args) - if not selected_files: - print('No files selected') + except KeyboardInterrupt: + print('KeyboardInterrupt\n') sys.exit(0) - # now run the real tests - for target_file in selected_files: - if args.list_only: - print(target_file) - continue + except InvalidGitRepositoryError as err: + eprint(f'Invalid Git repository: {err}') + sys.exit(1) + except ValidationError as err: if args.verbose: - print(f'Validating {target_file}:') - validate_python_file(target_file, config_dict.get('tests'), args) + eprint(f'Validation error: {err}') + sys.exit(0) if __name__ == '__main__': -- cgit v1.3