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) --- .ruff.toml | 91 +++++++++++++ pygitchecker.py | 388 +++++++++++++++++++++++++++++------------------------ sample_config.json | 22 +-- 3 files changed, 318 insertions(+), 183 deletions(-) create mode 100644 .ruff.toml diff --git a/.ruff.toml b/.ruff.toml new file mode 100644 index 0000000..23428fe --- /dev/null +++ b/.ruff.toml @@ -0,0 +1,91 @@ +cache-dir = "~/.cache/ruff" +indent-width = 4 +line-length = 79 +target-version = "py312" + +[lint] +select = ["ALL"] +ignore = [ + "BLE001", + "COM812", + "D2", + "D4", + "EM101", # Exception must not use a string literal, assign to variable first + "EM102", # Exception must not use an f-string literal, assign to variable first + "S603", + "T201", + "TRY003" + # "UP020" +] + +# BLE001 - Do not catch blind exception: `Exception` +# C901 - `outside_temperature` is too complex (12 > 10) +# COM812 - Trailing comma missing +# D101 - Missing docstring in public class +# D102 - Missing docstring in public method +# D200 - One-line docstring should fit on one line +# D202 - No blank lines allowed after function docstring +# D203 - 1 blank line required before class docstring +# D205 - 1 blank line required between summary line and description +# D212 - Multi-line docstring summary should start at the first line +# D400 - First line should end with a period +# D401 - First line of docstring should be in imperative mood +# D403 - First word of the first line should be capitalized: `str` -> `Str` +# D415 - First line should end with a period, question mark, or exclamation point +# ERA001 - Found commented-out code +# FBT001 - Boolean-typed positional argument in function definition +# FBT002 - Boolean default positional argument in function definition +# INP001 - File `beinc_weechat.py` is part of an implicit namespace package. Add an `__init__.py` +# PTH100 - `os.path.abspath()` should be replaced by `Path.resolve()` +# PTH109 - `os.getcwd()` should be replaced by `Path.cwd()` +# PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()` +# PTH123 - `open()` should be replaced by `Path.open()` +# PLR0912 - Too many branches (13 > 12) +# PLR0913 - Too many arguments in function definition (6 > 5) +# PLR2004 - Magic value used in comparison, consider replacing `200` with a constant variable +# RUF012 - Mutable class attributes should be annotated with `typing.ClassVar` +# RUF013 - PEP 484 prohibits implicit `Optional` +# S101 - Use of `assert` detected +# S308 - Use of `mark_safe` may expose cross-site scripting vulnerabilities +# S603 - `subprocess` call: check for execution of untrusted input +# T201 - `print` found +# TRY003 - Avoid specifying long messages outside the exception class +# TRY300 - Consider moving this statement to an `else` block +# UP020 - Use builtin `open` + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] + +[format] +# Like Black, use double quotes for strings. +quote-style = "single" + +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" + +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = true + +# Like Black, automatically detect the appropriate line ending. +line-ending = "lf" + +# Enable auto-formatting of code examples in docstrings. Markdown, +# reStructuredText code/literal blocks and doctests are all supported. +# +# This is currently disabled by default, but it is planned for this +# to be opt-out in the future. +docstring-code-format = false + +# Set the line length limit used when formatting code snippets in +# docstrings. +# +# This only has an effect when the `docstring-code-format` setting is +# enabled. +docstring-code-line-length = "dynamic" + +[lint.flake8-quotes] +inline-quotes = "single" + +[lint.isort] +split-on-trailing-comma = false 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__': diff --git a/sample_config.json b/sample_config.json index 81b1e15..fcc68cd 100644 --- a/sample_config.json +++ b/sample_config.json @@ -1,15 +1,17 @@ { + "ignore_dirs": [".git", ".venv"], + "target_extensions": [".py"], "tests": [ - {"name": "black", - "params": ["black", "--check", "--diff", "--no-color", "-q", "%p"], - "check": true}, - {"name": "pyflakes", "params": ["pyflakes", "%p"], "check": true}, - {"name": "isort", - "params": ["isort", "--check", "--diff", "%p"], - "check": true}, - {"name": "pylint", - "params": ["pylint", "-r", "no", "--exit-zero", "%p"], - "check": true} + { + "name": "ruff_format", + "params": ["/usr/bin/ruff", "format", "--diff", "-q", "%p"], + "check": true + }, + { + "name": "ruff_check", + "params": ["ruff", "check", "%p"], + "check": false + } ] } -- cgit v1.3