summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.ruff.toml91
-rwxr-xr-xpygitchecker.py384
-rw-r--r--sample_config.json22
3 files changed, 316 insertions, 181 deletions
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000..23428fe
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,91 @@
1cache-dir = "~/.cache/ruff"
2indent-width = 4
3line-length = 79
4target-version = "py312"
5
6[lint]
7select = ["ALL"]
8ignore = [
9 "BLE001",
10 "COM812",
11 "D2",
12 "D4",
13 "EM101", # Exception must not use a string literal, assign to variable first
14 "EM102", # Exception must not use an f-string literal, assign to variable first
15 "S603",
16 "T201",
17 "TRY003"
18 # "UP020"
19]
20
21# BLE001 - Do not catch blind exception: `Exception`
22# C901 - `outside_temperature` is too complex (12 > 10)
23# COM812 - Trailing comma missing
24# D101 - Missing docstring in public class
25# D102 - Missing docstring in public method
26# D200 - One-line docstring should fit on one line
27# D202 - No blank lines allowed after function docstring
28# D203 - 1 blank line required before class docstring
29# D205 - 1 blank line required between summary line and description
30# D212 - Multi-line docstring summary should start at the first line
31# D400 - First line should end with a period
32# D401 - First line of docstring should be in imperative mood
33# D403 - First word of the first line should be capitalized: `str` -> `Str`
34# D415 - First line should end with a period, question mark, or exclamation point
35# ERA001 - Found commented-out code
36# FBT001 - Boolean-typed positional argument in function definition
37# FBT002 - Boolean default positional argument in function definition
38# INP001 - File `beinc_weechat.py` is part of an implicit namespace package. Add an `__init__.py`
39# PTH100 - `os.path.abspath()` should be replaced by `Path.resolve()`
40# PTH109 - `os.getcwd()` should be replaced by `Path.cwd()`
41# PTH111 - `os.path.expanduser()` should be replaced by `Path.expanduser()`
42# PTH123 - `open()` should be replaced by `Path.open()`
43# PLR0912 - Too many branches (13 > 12)
44# PLR0913 - Too many arguments in function definition (6 > 5)
45# PLR2004 - Magic value used in comparison, consider replacing `200` with a constant variable
46# RUF012 - Mutable class attributes should be annotated with `typing.ClassVar`
47# RUF013 - PEP 484 prohibits implicit `Optional`
48# S101 - Use of `assert` detected
49# S308 - Use of `mark_safe` may expose cross-site scripting vulnerabilities
50# S603 - `subprocess` call: check for execution of untrusted input
51# T201 - `print` found
52# TRY003 - Avoid specifying long messages outside the exception class
53# TRY300 - Consider moving this statement to an `else` block
54# UP020 - Use builtin `open`
55
56# Allow fix for all enabled rules (when `--fix`) is provided.
57fixable = ["ALL"]
58unfixable = []
59
60[format]
61# Like Black, use double quotes for strings.
62quote-style = "single"
63
64# Like Black, indent with spaces, rather than tabs.
65indent-style = "space"
66
67# Like Black, respect magic trailing commas.
68skip-magic-trailing-comma = true
69
70# Like Black, automatically detect the appropriate line ending.
71line-ending = "lf"
72
73# Enable auto-formatting of code examples in docstrings. Markdown,
74# reStructuredText code/literal blocks and doctests are all supported.
75#
76# This is currently disabled by default, but it is planned for this
77# to be opt-out in the future.
78docstring-code-format = false
79
80# Set the line length limit used when formatting code snippets in
81# docstrings.
82#
83# This only has an effect when the `docstring-code-format` setting is
84# enabled.
85docstring-code-line-length = "dynamic"
86
87[lint.flake8-quotes]
88inline-quotes = "single"
89
90[lint.isort]
91split-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 @@
1#!/usr/bin/env python 1#!/usr/bin/env python
2# -*- coding: utf-8 -*-
3"""Simple, on demand git checker""" 2"""Simple, on demand git checker"""
3
4import argparse 4import argparse
5import errno 5import errno
6import io
7import json 6import json
8import os 7import os
8import pathlib
9import subprocess 9import subprocess
10import sys 10import sys
11 11
12import git 12import git
13 13
14 14
15class InvalidGitRepositoryError(Exception):
16 """
17 InvalidGitRepositoryError
18
19 Raised when invalid repository path is provided
20 """
21
22
15class ValidationError(Exception): 23class ValidationError(Exception):
16 """ValidationError""" 24 """ValidationError"""
17 25
18 26
19def eprint(*arg, **kwargs): 27def eprint(
28 *value: object, sep: str | None = ' ', end: str | None = '\n'
29) -> None:
20 """stdderr print wrapper""" 30 """stdderr print wrapper"""
21 print(*arg, file=sys.stderr, flush=True, **kwargs) 31 print(*value, sep=sep, end=end, file=sys.stderr, flush=True)
22
23 32
24def is_git_repo(abspath):
25 """True of abspath points to a git repo, False otherwise"""
26 if not os.path.isdir(abspath):
27 return False
28 try:
29 git.Repo(abspath)
30 return True
31 except git.InvalidGitRepositoryError:
32 return False
33 33
34class CLIHandler:
35 """Helper class used for handleing the growing amount of arguments"""
34 36
35def modifications_exist(abspath): 37 def __init__(
36 """ 38 self, args: argparse.Namespace, config_dict: dict[str, list]
37 True if modifications exist for git repo representing abspath 39 ) -> None:
38 False if no modifications exist 40 """
41 :param args: The parsed argparse arguments sent by the caller
42 :type args: argparse.Namespace
39 43
40 git.InvalidGitRepositoryError raised if abspath points to illegal repo 44 :param config_dict: The config file structure
41 """ 45 :type config_dict: dict
42 repo = git.Repo(abspath) 46 """
43 return bool(repo.is_dirty() or repo.index.diff(None)) 47 self._args = args
48 self._config_dict = config_dict
49 self._py_checker = PyChecker(
50 tests=self._config_dict['tests'],
51 skip=args.skip.split(',') if args.skip else None,
52 verbose=args.verbose,
53 )
54 self._repository_path = pathlib.Path(args.repo_path).absolute()
44 55
56 def handle_args(self) -> None:
57 """Runs the handler"""
58 if self._args.python_file:
59 self._py_checker.validate_python_file(self._args.python_file)
60 return
45 61
46def print_modification_status(abspath, status, args): 62 repository_handler = GitRepositoryHandler(
47 """Prints the modification status for the given abspath""" 63 self._repository_path,
48 if status: 64 target_extensions=self._config_dict['target_extensions'],
49 if args.verbose: 65 ignore_dirs=self._config_dict['ignore_dirs'],
50 print(f'{abspath} -> modified') 66 )
67 if self._args.all_files:
68 selected_files = repository_handler.get_all_target_files()
51 else: 69 else:
52 print(abspath) 70 selected_files = repository_handler.get_new_and_modified()
53 return status
54 if args.verbose:
55 print(f'{abspath} -> unchanged')
56 return status
57 71
72 if not selected_files:
73 print('No files selected')
74 return
58 75
59def fetch_git_repos(abspath: str): 76 for target_file in selected_files:
60 """Returns a list of git repo paths under abspath""" 77 if self._args.list_only:
61 repo_list = [] 78 print(target_file)
62 for root, dirs, _ in os.walk(abspath):
63 if '/.git' in root:
64 continue
65 if '.git' in dirs:
66 try:
67 git.Repo(root)
68 repo_list.append(root)
69 except git.InvalidGitRepositoryError:
70 continue 79 continue
71 return repo_list
72 80
81 if self._args.verbose:
82 print(f'Validating {target_file}:')
83 self._py_checker.validate_python_file(target_file)
73 84
74def fetch_git_files(args): 85 return
75 """Fetches new and modified .py-files using git""" 86
76 try: 87
77 repo = git.Repo(args.repo_path) 88class GitRepositoryHandler:
89 """Handler for the git repository logic"""
90
91 def __init__(
92 self,
93 repository_path: pathlib.Path,
94 target_extensions: list[str],
95 ignore_dirs: list[str] | None = None,
96 ) -> None:
97 """
98 Constructs handler for a specific repository path
99
100 It takes `target_extensions` as an input in order to define
101 a file as a target. Example ['.py'], ['.cpp', '.cc']
102
103 `ignore_dirs` is used to define a list of directory names to ignore
104 (not traverse in) by `get_all_target_files`.
105
106 :param repository_path: Absolute path of the git repository
107 :type repository_path: pathlib.Path
108
109 :param target_extensions: A list of file extensions (f.i. ['.py'])
110 :type target_extensions: list
111
112 :param ignore_dirs: A list of directory names to ignore when
113 :type ignore_dirs: list or None
114 """
115 self._repository_path = repository_path
116 self._target_extensions = target_extensions
117 self._ignore_dirs: list[str] = (
118 ignore_dirs if ignore_dirs is not None else []
119 )
120
121 if not self._repository_path.is_dir():
122 raise InvalidGitRepositoryError(
123 'Specified path is not a directory'
124 )
125
126 try:
127 self._git_repository = git.Repo(self._repository_path)
128 except git.InvalidGitRepositoryError as err:
129 raise InvalidGitRepositoryError(
130 'Specified path is pointing to a directory that is not a '
131 'Git repository'
132 ) from err
133
134 def get_all_target_files(self) -> list[pathlib.Path]:
135 """
136 Fetches all .py-files
137
138 :return: A list containing the paths of all .py files
139 :rtype: list
140 """
141 all_files_list = []
142
143 for root, _, files in os.walk(self._repository_path):
144 if any(idir in root for idir in self._ignore_dirs):
145 continue
146 all_files_list.extend(
147 pathlib.Path(root) / f
148 for f in files
149 if pathlib.Path(f).suffix.lower() in self._target_extensions
150 )
151
152 all_files_list.sort()
153 return all_files_list
154
155 def get_new_and_modified(self) -> list[pathlib.Path]:
156 """
157 Fetches new and modified .py-files using git
158
159 :return: A list of new and modified file paths
160 :rtype: list
161 """
78 modified_files = { 162 modified_files = {
79 mfile.a_path 163 self._repository_path / pathlib.Path(mfile.a_path)
80 for mfile in repo.index.diff(None) 164 for mfile in self._git_repository.index.diff(None)
81 if mfile.a_path.lower().endswith('.py') 165 if mfile.a_path is not None
166 and mfile.a_path.lower().endswith('.py')
82 } 167 }
83 staged_files = { 168 staged_files = {
84 sfile.a_path 169 self._repository_path / pathlib.Path(sfile.a_path)
85 for sfile in repo.index.diff('HEAD') 170 for sfile in self._git_repository.index.diff('HEAD')
86 if sfile.a_path.lower().endswith('.py') 171 if sfile.a_path is not None
172 and sfile.a_path.lower().endswith('.py')
173 }
174 untracked_files = {
175 self._repository_path / pathlib.Path(ufile)
176 for ufile in self._git_repository.untracked_files
177 if ufile is not None and ufile.lower().endswith('.py')
87 } 178 }
88 untracked_files = set(
89 filter(lambda f: f.lower().endswith('.py'), repo.untracked_files)
90 )
91 return list(modified_files | staged_files | untracked_files)
92 except git.InvalidGitRepositoryError:
93 eprint(f'{args.repo_path} is not a valid git repository')
94 sys.exit(1)
95 179
180 return sorted(modified_files | staged_files | untracked_files)
96 181
97def fetch_all_files(abspath):
98 """Fetches all .py-files"""
99 all_files_list = []
100 for root, _, files in os.walk(abspath):
101 for f in files:
102 if os.path.splitext(f)[1].lower() == '.py':
103 all_files_list.append(os.path.join(abspath, root, f))
104 return all_files_list
105 182
183class PyChecker:
184 """Implements the checker logic"""
106 185
107def validate_python_file(pfile, tests, args): 186 def __init__(
108 """ 187 self,
109 Validates a single .py file against tests 188 tests: list[dict],
189 skip: list[str] | None = None,
190 *,
191 verbose: bool = False,
192 ) -> None:
193 """
194 :param tests: A list of test dicts
195 :type tests: list
110 196
111 :param tests: A sequence of test dicts 197 :param skip: A list of test names to skip (default: None)
112 :type tests: collections.Sequence 198 :type skip: list or None
113 199
114 :param pfile: Python file (abs. path) 200 :param verbose: Turn on verbosity (default: False)
115 :type pfile: str 201 :type verbose: bool
202 """
203 self._tests = tests
204 self._skip = skip if skip is not None else []
205 self._verbose = verbose
116 206
117 :param args: Argparse Namespace-object 207 def validate_python_file(self, pfile: pathlib.Path) -> None:
118 :type args: argparse.Namespace 208 """
209 Validates a single .py file against tests
119 210
120 :return: False if validation fails, False otherwise 211 :param pfile: Python file (absolute path)
121 :rtype: bool 212 :type pfile: pathlib.Path
122 """
123 skipped = []
124 if args.skip:
125 skipped = args.skip.split(',')
126 for test in tests:
127 if test['name'] in skipped:
128 if args.verbose:
129 print(f'{pfile} <- {test["name"]} (skipping)')
130 continue
131 if args.verbose:
132 print(f'{pfile} <- {test["name"]}')
133 try:
134 params = [param.replace('%p', pfile) for param in test['params']]
135 subprocess.run(params, check=test['check'])
136 except subprocess.CalledProcessError:
137 if not args.continue_checks:
138 if args.verbose:
139 eprint(f'{pfile}: {test["name"]} validation failed')
140 sys.exit(0)
141 213
214 :return: False if validation fails, False otherwise
215 :rtype: bool
216 """
217 for test in self._tests:
218 if test['name'] in self._skip:
219 if self._verbose:
220 print(f'{pfile} <- {test["name"]} (skipping)')
221 continue
222 try:
223 params = [
224 param.replace('%p', str(pfile)) for param in test['params']
225 ]
226 if self._verbose:
227 print(f'{pfile} <- {test["name"]} ({" ".join(params)})')
228 subprocess.run(params, check=test['check'])
229 except subprocess.CalledProcessError as err:
230 raise ValidationError(
231 f'{pfile}: {test["name"]} validation failed'
232 ) from err
142 233
143def main(inargs=None): 234
235def main(inargs: list[str] | None = None) -> None:
144 """Main entry point""" 236 """Main entry point"""
145 parser = argparse.ArgumentParser( 237 parser = argparse.ArgumentParser(
146 description='The following options are available' 238 description='The following options are available'
147 ) 239 )
148 parser.add_argument( 240 parser.add_argument(
149 'repo_path', 241 'repo_path',
150 metavar='REPO', 242 metavar='<Git repository path>',
151 nargs='?', 243 nargs='?',
152 default=os.getcwd(), 244 default=pathlib.Path.cwd(),
153 help='Git repo path (default: cwd)', 245 help='Git repo path (default: cwd)',
154 ) 246 )
155 parser.add_argument( 247 parser.add_argument(
@@ -160,18 +252,11 @@ def main(inargs=None):
160 help='Check all files, not only the modified ones', 252 help='Check all files, not only the modified ones',
161 ) 253 )
162 parser.add_argument( 254 parser.add_argument(
163 '-C',
164 '--continue-checks',
165 action='store_true',
166 dest='continue_checks',
167 help='Continue with the checks if validation fails',
168 )
169 parser.add_argument(
170 '-c', 255 '-c',
171 '--config', 256 '--config',
172 metavar='<file>', 257 metavar='<file>',
173 type=str, 258 type=pathlib.Path,
174 default=os.path.expanduser('~/.pygitchecker.json'), 259 default=pathlib.Path('~/.pygitchecker.json').expanduser(),
175 dest='config_file', 260 dest='config_file',
176 help='Config file (default: ~/.pygitchecker.json)', 261 help='Config file (default: ~/.pygitchecker.json)',
177 ) 262 )
@@ -185,13 +270,6 @@ def main(inargs=None):
185 help='Validate a particular .py file', 270 help='Validate a particular .py file',
186 ) 271 )
187 parser.add_argument( 272 parser.add_argument(
188 '-f',
189 '--full',
190 action='store_true',
191 dest='full_search',
192 help='Consider repos that contain untracked files (used with -m)',
193 )
194 parser.add_argument(
195 '-l', 273 '-l',
196 '--list-only', 274 '--list-only',
197 action='store_true', 275 action='store_true',
@@ -199,20 +277,6 @@ def main(inargs=None):
199 help='Only lists the target files without performing any checks', 277 help='Only lists the target files without performing any checks',
200 ) 278 )
201 parser.add_argument( 279 parser.add_argument(
202 '-m',
203 '--modifications-only',
204 action='store_true',
205 dest='mod_only',
206 help='Only check for git modifications',
207 )
208 parser.add_argument(
209 '-r',
210 '--recursive',
211 action='store_true',
212 dest='recursive',
213 help='Look for several repos recursively',
214 )
215 parser.add_argument(
216 '-s', 280 '-s',
217 '--skip', 281 '--skip',
218 metavar='<test1[,test2,test3...]>', 282 metavar='<test1[,test2,test3...]>',
@@ -228,52 +292,30 @@ def main(inargs=None):
228 dest='verbose', 292 dest='verbose',
229 help='Verbosity', 293 help='Verbosity',
230 ) 294 )
295
231 args = parser.parse_args(inargs) 296 args = parser.parse_args(inargs)
297
232 try: 298 try:
233 with io.open(args.config_file, 'r', encoding='utf-8') as fp: 299 with pathlib.Path(args.config_file).open(encoding='utf-8') as fp:
234 config_dict = json.load(fp) 300 config_dict = json.load(fp)
235 except Exception as e: 301 except Exception as exp:
236 eprint(f'Unable to parse {args.config_file}: {e}') 302 eprint(f'Unable to parse {args.config_file}: {exp}')
237 sys.exit(errno.EIO) 303 sys.exit(errno.EIO)
238 if args.python_file: 304
239 validate_python_file(args.python_file, config_dict.get('tests'), args) 305 try:
240 sys.exit(0) 306 cli_handler = CLIHandler(args, config_dict)
241 abs_repo_path = os.path.abspath(os.path.expanduser(args.repo_path)) 307 cli_handler.handle_args()
242 if args.mod_only:
243 if not args.recursive:
244 if not is_git_repo(abs_repo_path):
245 eprint(f'{abs_repo_path} is not a valid git repository')
246 sys.exit(1)
247 print_modification_status(
248 abs_repo_path, modifications_exist(abs_repo_path), args
249 )
250 sys.exit(0)
251 # resursive
252 repo_list = fetch_git_repos(abs_repo_path)
253 if not repo_list:
254 print('No git-repos found')
255 sys.exit(0)
256 repo_list.sort()
257 for repo_path in repo_list:
258 print_modification_status(
259 repo_path, modifications_exist(repo_path), args
260 )
261 sys.exit(0) 308 sys.exit(0)
262 if args.all_files: 309 except KeyboardInterrupt:
263 selected_files = fetch_all_files(abs_repo_path) 310 print('KeyboardInterrupt\n')
264 else:
265 selected_files = fetch_git_files(args)
266 if not selected_files:
267 print('No files selected')
268 sys.exit(0) 311 sys.exit(0)
269 # now run the real tests 312 except InvalidGitRepositoryError as err:
270 for target_file in selected_files: 313 eprint(f'Invalid Git repository: {err}')
271 if args.list_only: 314 sys.exit(1)
272 print(target_file) 315 except ValidationError as err:
273 continue
274 if args.verbose: 316 if args.verbose:
275 print(f'Validating {target_file}:') 317 eprint(f'Validation error: {err}')
276 validate_python_file(target_file, config_dict.get('tests'), args) 318 sys.exit(0)
277 319
278 320
279if __name__ == '__main__': 321if __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 @@
1{ 1{
2 "ignore_dirs": [".git", ".venv"],
3 "target_extensions": [".py"],
2 "tests": [ 4 "tests": [
3 {"name": "black", 5 {
4 "params": ["black", "--check", "--diff", "--no-color", "-q", "%p"], 6 "name": "ruff_format",
5 "check": true}, 7 "params": ["/usr/bin/ruff", "format", "--diff", "-q", "%p"],
6 {"name": "pyflakes", "params": ["pyflakes", "%p"], "check": true}, 8 "check": true
7 {"name": "isort", 9 },
8 "params": ["isort", "--check", "--diff", "%p"], 10 {
9 "check": true}, 11 "name": "ruff_check",
10 {"name": "pylint", 12 "params": ["ruff", "check", "%p"],
11 "params": ["pylint", "-r", "no", "--exit-zero", "%p"], 13 "check": false
12 "check": true} 14 }
13 ] 15 ]
14 16
15} 17}