-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy path_utils.py
More file actions
243 lines (191 loc) · 9.31 KB
/
_utils.py
File metadata and controls
243 lines (191 loc) · 9.31 KB
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
# Copyright 2021 Damien Nguyen
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Base classes and functions for C/C++ linters and formatters."""
from __future__ import annotations
import json
import logging
import os
import sys
from pathlib import Path
import hooks.utils
from . import _argparse, _call_process
from ._cmake import CMakeCommand
_LOGLEVEL = os.environ.get('LOGLEVEL', 'INFO').upper()
logging.basicConfig(level=_LOGLEVEL, format='%(levelname)-5s:cmake-pc-hooks:%(message)s')
logging.getLogger('filelock').setLevel(logging.WARNING)
log = logging.getLogger(__name__)
class CMakePresetError(Exception):
"""Exception raised if a command line incompatibility with --preset is detected."""
def __init__(self) -> None:
super().__init__('You *must* specify -B|--build-dir if you pass --preset as a CMake argument!')
def _read_compile_commands_json(compile_db: Path) -> list[str]:
"""Read a JSON compile database and return the list of files contained within."""
if compile_db.exists():
with compile_db.open(encoding='utf-8') as fd:
data = json.load(fd)
return [entry['file'] for entry in data]
return []
class Command(hooks.utils.Command): # pylint: disable=too-many-instance-attributes
"""Super class that all commands inherit."""
setup_cmake = True
def __init__(self, command, look_behind, args):
"""Initialize a Command object."""
super().__init__(command, look_behind, args)
self.ddash_args = []
self.cmake = CMakeCommand()
self.clean_build = False
self.all_at_once = False
self.read_json_db = False
self.build_dir_list = ['.', CMakeCommand.DEFAULT_BUILD_DIR]
self.history = []
def parse_args(self, args):
"""
Parse some arguments into some usable variables.
Args:
args (:obj:`list` of :obj:`str`): list of arguments
"""
parser = _argparse.ArgumentParser(
default_config_name='cmake_pc_hooks.toml',
pyproject_section_name='tool.cmake_pc_hooks',
args_groups=[{'title': 'Hook options'}],
)
self.cmake.add_cmake_arguments_to_parser(parser)
hook_options = parser.groups[0]
hook_options.add_argument(
'--all-at-once',
action='store_true',
help='Pass all filenames at once to the linter/formatter instead of calling the command once for each file',
)
hook_options.add_argument(
'--read-json-db',
action='store_true',
help=(
'Run hooks on files found in compile_commands.json (if found and in addition to files specified on CLI)'
),
)
# Other options
hook_options.add_argument('--version', type=str, help='Perform a version check')
hook_options.add_argument('positionals', metavar='filenames', nargs='*', help='Filenames to check')
known_args, self.args = parser.parse_known_args(args[1:])
self.all_at_once = known_args.all_at_once
self.read_json_db = known_args.read_json_db
self.clean_build = known_args.clean
self.build_dir_list.extend(known_args.build_dir or [])
if not known_args.build_dir and known_args.preset:
raise CMakePresetError
if self.setup_cmake:
self.cmake.setup_cmake_args(known_args)
if not self.cmake.source_dir.exists() and not self.cmake.source_dir.is_dir():
sys.stderr.write(f'{self.cmake.source_dir} is not a valid source directory\n')
sys.exit(1)
if known_args.version:
actual_version = self.get_version_str()
self.assert_version(actual_version, known_args.version)
# NB: if '--' may be present on the command line, the command class for that particular command needs to call
# handle_ddash_args() in order to properly handle the filenames in those cases.
self.files = known_args.positionals
def run(self):
"""Run the command."""
self.cmake.configure(self.command)
self.files.extend(self.cmake.cmake_configured_files)
compile_db = self._resolve_compilation_database(self.cmake.build_dir, self.build_dir_list)
if self.read_json_db and compile_db:
self.files.extend(set(_read_compile_commands_json(compile_db)) - set(self.files))
if self.all_at_once:
self.run_command(self.files)
elif self.files:
for filename in self.files:
self.run_command([filename])
else:
log.error('No files to process!')
sys.exit(1)
has_errors = False
for res in self.history:
has_errors |= self._parse_output(res)
res.to_stdout_and_stderr()
if has_errors:
sys.exit(1)
def run_command(self, filenames): # pylint: disable=arguments-differ,arguments-renamed
"""Run the command and check for errors."""
self.history.append(_call_process.call_process([self.command, *filenames, *self.args, *self.ddash_args]))
self._clinters_compat()
def _clinters_compat(self):
"""Compatibility with CLinters."""
self.stdout = self.history[-1].stdout.encode()
self.stderr = self.history[-1].stderr.encode()
self.returncode = self.history[-1].returncode
def _parse_output(self, result): # noqa: ARG002, PLR6301
return NotImplemented
@staticmethod
def _resolve_compilation_database(cmake_build_dir: Path, build_dir_list: list[Path]) -> Path | None:
"""Locate a compilation database based on internal list of directories."""
if cmake_build_dir and cmake_build_dir / 'compile_commands.json':
return cmake_build_dir / 'compile_commands.json'
build_dir_list = [] if build_dir_list is None else [Path(path) for path in build_dir_list]
for build_dir in build_dir_list:
path = Path(build_dir, 'compile_commands.json')
if build_dir.exists() and path.exists():
log.debug('Located valid compilation database at: %s', path)
return path
log.debug('No valid compilation database located')
return None
class ClangAnalyzerCmd(Command):
"""Commands that statically analyze code: clang-tidy, oclint."""
def handle_ddash_args(self):
"""
Handle arguments after a '--'.
Pre-commit sends a list of files as the last argument which may cause problems with -- and some programs such as
clang-tidy. This function converts the filename arguments in order to make everything work as expected.
Example:
clang-tidy --checks=* -- -std=c++17 file1.cpp file2.cpp
will be turned into:
clang-tidy file1.cpp file2.cpp --checks=* -- -std=c++17
In the case above, the content of self.files would be: ['-std=c++17', 'file1.cpp', 'file2.cpp'] which needs
to be converted to: ['file1.cpp', 'file2.cpp'] while ['--', '-std=c++17'] should be added to `self.args`.
"""
files = []
other_args = []
for _, fname in enumerate(reversed(self.files)):
if Path(fname).is_file():
files.append(fname)
else:
other_args.append(fname)
if other_args:
self.ddash_args.extend(['--', *other_args])
self.files = list(reversed(files))
class FormatterCmd(Command, hooks.utils.FormatterCmd):
"""Commands that format code: clang-format, uncrustify."""
setup_cmake = False
def __init__(self, command: str, look_behind: str, args: list[str]):
super().__init__(command, look_behind, args)
self.dry_run = '-n' in self.args or '--dry-run' in self.args
def get_formatted_lines(self, filename: bytes) -> list: # pragma: nocover
"""Get the expected output for a command applied to a file."""
filename_opts = self.get_filename_opts(filename)
args = [self.command, *self.args, *filename_opts]
# NB: only change w.r.t original method to handle both bytes and string argument types
child = _call_process.call_process([arg.decode() if isinstance(arg, bytes) else arg for arg in args])
if len(child.stderr) > 0 or child.returncode != 0:
problem = f'Unexpected Stderr/return code received when analyzing {filename}.\nArgs: {args}'
self.raise_error(problem, child.stdout + child.stderr)
if self.dry_run:
# clang-format dry-run mode is '-n'
# If dry-run mode then we only look at the return code -> read the lines
self.returncode = 0
return self.get_filelines(filename)
if not child.stdout:
return []
return child.stdout.encode().split(b'\x0a')
class StaticAnalyzerCmd(Command, hooks.utils.StaticAnalyzerCmd):
"""Commands that analyze code and are not formatters."""