summaryrefslogtreecommitdiff
path: root/pygitchecker.py
blob: aad9a0521114e62c36cbcad2631274f5f4348340 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
#!/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='<Git repository path>',
        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='<file>',
        type=pathlib.Path,
        default=pathlib.Path('~/.pygitchecker.json').expanduser(),
        dest='config_file',
        help='Config file (default: ~/.pygitchecker.json)',
    )
    parser.add_argument(
        '-F',
        '--file',
        metavar='<file>',
        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='<test1[,test2,test3...]>',
        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()