Lint.generate_config_file()   B
last analyzed

Complexity

Conditions 5

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
dl 0
loc 18
rs 8.5454
c 0
b 0
f 0
1
import os
2
import re
3
import shlex
4
import shutil
5
from coala_utils.string_processing import escape
6
from subprocess import check_call, CalledProcessError, DEVNULL
7
import tempfile
8
9
from coalib.bears.Bear import Bear
10
from coala_utils.decorators import enforce_signature
11
from coalib.misc.Shell import run_shell_command, get_shell_type
12
from coalib.results.Diff import Diff
13
from coalib.results.Result import Result
14
from coalib.results.RESULT_SEVERITY import RESULT_SEVERITY
15
16
17
def escape_path_argument(path, shell=get_shell_type()):
18
    """
19
    Makes a raw path ready for using as parameter in a shell command (escapes
20
    illegal characters, surrounds with quotes etc.).
21
22
    :param path:  The path to make ready for shell.
23
    :param shell: The shell platform to escape the path argument for. Possible
24
                  values are "sh", "powershell", and "cmd" (others will be
25
                  ignored and return the given path without modification).
26
    :return:      The escaped path argument.
27
    """
28
    if shell == "cmd":
29
        # If a quote (") occurs in path (which is illegal for NTFS file
30
        # systems, but maybe for others), escape it by preceding it with
31
        # a caret (^).
32
        return '"' + escape(path, '"', '^') + '"'
33
    elif shell == "sh":
34
        return shlex.quote(path)
35
    else:
36
        # Any other non-supported system doesn't get a path escape.
37
        return path
38
39
40
class Lint(Bear):
41
42
    """
43
    Deals with the creation of linting bears.
44
45
    For the tutorial see:
46
    http://coala.readthedocs.org/en/latest/Users/Tutorials/Linter_Bears.html
47
48
    :param executable:                  The executable to run the linter.
49
    :param prerequisite_command:        The command to run as a prerequisite
50
                                        and is of type ``list``.
51
    :param prerequisites_fail_msg:      The message to be displayed if the
52
                                        prerequisite fails.
53
    :param arguments:                   The arguments to supply to the linter,
54
                                        such that the file name to be analyzed
55
                                        can be appended to the end. Note that
56
                                        we use ``.format()`` on the arguments -
57
                                        so, ``{abc}`` needs to be given as
58
                                        ``{{abc}}``. Currently, the following
59
                                        will be replaced:
60
61
                                         - ``{filename}`` - The filename passed
62
                                           to ``lint()``
63
                                         - ``{config_file}`` - The config file
64
                                           created using ``config_file()``
65
66
    :param output_regex:    The regex which will match the output of the linter
67
                            to get results. This is not used if
68
                            ``gives_corrected`` is set. This regex should give
69
                            out the following variables:
70
71
                             - line - The line where the issue starts.
72
                             - column - The column where the issue starts.
73
                             - end_line - The line where the issue ends.
74
                             - end_column - The column where the issue ends.
75
                             - severity - The severity of the issue.
76
                             - message - The message of the result.
77
                             - origin - The origin of the issue.
78
79
    :param diff_severity:   The severity to use for all results if
80
                            ``gives_corrected`` is set.
81
    :param diff_message:    The message to use for all results if
82
                            ``gives_corrected`` is set.
83
    :param use_stderr:      Uses stderr as the output stream is it's True.
84
    :param use_stdin:       Sends file as stdin instead of giving the file name.
85
    :param gives_corrected: True if the executable gives the corrected file
86
                            or just the issues.
87
    :param severity_map:    A dict where the keys are the possible severity
88
                            values the Linter gives out and the values are the
89
                            severity of the coala Result to set it to. If it is
90
                            not a dict, it is ignored.
91
    """
92
    executable = None
93
    prerequisite_command = None
94
    prerequisite_fail_msg = 'Unknown failure.'
95
    arguments = ""
96
    output_regex = re.compile(r'(?P<line>\d+)\.(?P<column>\d+)\|'
97
                              r'(?P<severity>\d+): (?P<message>.*)')
98
    diff_message = 'No result message was set'
99
    diff_severity = RESULT_SEVERITY.NORMAL
100
    use_stderr = False
101
    use_stdin = False
102
    gives_corrected = False
103
    severity_map = None
104
105
    def lint(self, filename=None, file=None):
106
        """
107
        Takes a file and lints it using the linter variables defined apriori.
108
109
        :param filename:  The name of the file to execute.
110
        :param file:      The contents of the file as a list of strings.
111
        """
112
        assert ((self.use_stdin and file is not None) or
113
                (not self.use_stdin and filename is not None))
114
115
        config_file = self.generate_config_file()
116
        self.command = self._create_command(filename=filename,
117
                                            config_file=config_file)
118
119
        stdin_input = "".join(file) if self.use_stdin else None
120
        stdout_output, stderr_output = run_shell_command(self.command,
121
                                                         stdin=stdin_input,
122
                                                         shell=True)
123
        self.stdout_output = tuple(stdout_output.splitlines(keepends=True))
124
        self.stderr_output = tuple(stderr_output.splitlines(keepends=True))
125
        results_output = (self.stderr_output if self.use_stderr
126
                          else self.stdout_output)
127
        results = self.process_output(results_output, filename, file)
128
        if not self.use_stderr:
129
            self._print_errors(self.stderr_output)
130
131
        if config_file:
132
            os.remove(config_file)
133
134
        return results
135
136
    def process_output(self, output, filename, file):
137
        """
138
        Take the output (from stdout or stderr) and use it to create Results.
139
        If the class variable ``gives_corrected`` is set to True, the
140
        ``_process_corrected()`` is called. If it is False,
141
        ``_process_issues()`` is called.
142
143
        :param output:   The output to be used to obtain Results from. The
144
                         output is either stdout or stderr depending on the
145
                         class variable ``use_stderr``.
146
        :param filename: The name of the file whose output is being processed.
147
        :param file:     The contents of the file whose output is being
148
                         processed.
149
        :return:         Generator which gives Results produced based on this
150
                         output.
151
        """
152
        if self.gives_corrected:
153
            return self._process_corrected(output, filename, file)
154
        else:
155
            return self._process_issues(output, filename)
156
157
    def _process_corrected(self, output, filename, file):
158
        """
159
        Process the output and use it to create Results by creating diffs.
160
        The diffs are created by comparing the output and the original file.
161
162
        :param output:   The corrected file contents.
163
        :param filename: The name of the file.
164
        :param file:     The original contents of the file.
165
        :return:         Generator which gives Results produced based on the
166
                         diffs created by comparing the original and corrected
167
                         contents.
168
        """
169
        for diff in self.__yield_diffs(file, output):
170
            yield Result(self,
171
                         self.diff_message,
172
                         affected_code=(diff.range(filename),),
173
                         diffs={filename: diff},
174
                         severity=self.diff_severity)
175
176
    def _process_issues(self, output, filename):
177
        """
178
        Process the output using the regex provided in ``output_regex`` and
179
        use it to create Results by using named captured groups from the regex.
180
181
        :param output:   The output to be parsed by regex.
182
        :param filename: The name of the file.
183
        :param file:     The original contents of the file.
184
        :return:         Generator which gives Results produced based on regex
185
                         matches using the ``output_regex`` provided and the
186
                         ``output`` parameter.
187
        """
188
        regex = self.output_regex
189
        if isinstance(regex, str):
190
            regex = regex % {"file_name": filename}
191
192
        # Note: We join ``output`` because the regex may want to capture
193
        #       multiple lines also.
194
        for match in re.finditer(regex, "".join(output)):
195
            yield self.match_to_result(match, filename)
196
197
    def _get_groupdict(self, match):
198
        """
199
        Convert a regex match's groups into a dictionary with data to be used
200
        to create a Result. This is used internally in ``match_to_result``.
201
202
        :param match:    The match got from regex parsing.
203
        :param filename: The name of the file from which this match is got.
204
        :return:         The dictionary containing the information:
205
                         - line - The line where the result starts.
206
                         - column - The column where the result starts.
207
                         - end_line - The line where the result ends.
208
                         - end_column - The column where the result ends.
209
                         - severity - The severity of the result.
210
                         - message - The message of the result.
211
                         - origin - The origin of the result.
212
        """
213
        groups = match.groupdict()
214
        if (
215
                isinstance(self.severity_map, dict) and
216
                "severity" in groups and
217
                groups["severity"] in self.severity_map):
218
            groups["severity"] = self.severity_map[groups["severity"]]
219
        return groups
220
221
    def _create_command(self, **kwargs):
222
        command = self.executable + ' ' + self.arguments
223
        for key in ("filename", "config_file"):
224
            kwargs[key] = escape_path_argument(kwargs.get(key, "") or "")
225
        return command.format(**kwargs)
226
227
    def _print_errors(self, errors):
228
        for line in filter(lambda error: bool(error.strip()), errors):
229
            self.warn(line)
230
231
    @staticmethod
232
    def __yield_diffs(file, new_file):
233
        if tuple(new_file) != tuple(file):
234
            wholediff = Diff.from_string_arrays(file, new_file)
235
236
            for diff in wholediff.split_diff():
237
                yield diff
238
239
    def match_to_result(self, match, filename):
240
        """
241
        Convert a regex match's groups into a coala Result object.
242
243
        :param match:    The match got from regex parsing.
244
        :param filename: The name of the file from which this match is got.
245
        :return:         The Result object.
246
        """
247
        groups = self._get_groupdict(match)
248
249
        # Pre process the groups
250
        for variable in ("line", "column", "end_line", "end_column"):
251
            if variable in groups and groups[variable]:
252
                groups[variable] = int(groups[variable])
253
254
        if "origin" in groups:
255
            groups['origin'] = "{} ({})".format(str(self.name),
256
                                                str(groups["origin"]))
257
258
        return Result.from_values(
259
            origin=groups.get("origin", self),
260
            message=groups.get("message", ""),
261
            file=filename,
262
            severity=int(groups.get("severity", RESULT_SEVERITY.NORMAL)),
263
            line=groups.get("line", None),
264
            column=groups.get("column", None),
265
            end_line=groups.get("end_line", None),
266
            end_column=groups.get("end_column", None))
267
268
    @classmethod
269
    def check_prerequisites(cls):
270
        """
271
        Checks for prerequisites required by the Linter Bear.
272
273
        It uses the class variables:
274
        -  ``executable`` - Checks that it is available in the PATH using
275
        ``shutil.which``.
276
        -  ``prerequisite_command`` - Checks that when this command is run,
277
        the exitcode is 0. If it is not zero, ``prerequisite_fail_msg``
278
        is gives as the failure message.
279
280
        If either of them is set to ``None`` that check is ignored.
281
282
        :return: True is all checks are valid, else False.
283
        """
284
        return cls._check_executable_command(
285
            executable=cls.executable,
286
            command=cls.prerequisite_command,
287
            fail_msg=cls.prerequisite_fail_msg)
288
289
    @classmethod
290
    @enforce_signature
291
    def _check_executable_command(cls, executable,
292
                                  command: (list, tuple, None), fail_msg):
293
        """
294
        Checks whether the required executable is found and the
295
        required command successfully executes.
296
297
        The function is intended be used with classes having an
298
        executable, prerequisite_command and prerequisite_fail_msg.
299
300
        :param executable:   The executable to check for.
301
        :param command:      The command to check as a prerequisite.
302
        :param fail_msg:     The fail message to display when the
303
                             command doesn't return an exitcode of zero.
304
305
        :return: True if command successfully executes, or is not required.
306
                 not True otherwise, with a string containing a
307
                 detailed description of the error.
308
        """
309
        if cls._check_executable(executable):
310
            if command is None:
311
                return True  # when there are no prerequisites
312
            try:
313
                check_call(command, stdout=DEVNULL, stderr=DEVNULL)
314
                return True
315
            except (OSError, CalledProcessError):
316
                return fail_msg
317
        else:
318
            return repr(executable) + " is not installed."
319
320
    @staticmethod
321
    def _check_executable(executable):
322
        """
323
        Checks whether the needed executable is present in the system.
324
325
        :param executable: The executable to check for.
326
327
        :return: True if binary is present, or is not required.
328
                 not True otherwise, with a string containing a
329
                 detailed description of what's missing.
330
        """
331
        if executable is None:
332
            return True
333
        return shutil.which(executable) is not None
334
335
    def generate_config_file(self):
336
        """
337
        Generates a temporary config file.
338
        Note: The user of the function is responsible for deleting the
339
        tempfile when done with it.
340
341
        :return: The file name of the tempfile created.
342
        """
343
        config_lines = self.config_file()
344
        config_file = ""
345
        if config_lines is not None:
346
            for i, line in enumerate(config_lines):
347
                config_lines[i] = line if line.endswith("\n") else line + "\n"
348
            config_fd, config_file = tempfile.mkstemp()
349
            os.close(config_fd)
350
            with open(config_file, 'w') as conf_file:
351
                conf_file.writelines(config_lines)
352
        return config_file
353
354
    @staticmethod
355
    def config_file():
356
        """
357
        Returns a configuration file from the section given to the bear.
358
        The section is available in ``self.section``. To add the config
359
        file's name generated by this function to the arguments,
360
        use ``{config_file}``.
361
362
        :return: A list of lines of the config file to be used or None.
363
        """
364
        return None
365