Failed Conditions
Pull Request — master (#1814)
by Mischa
01:29
created

coalib.bearlib.abstractions.Linter   A

Complexity

Total Complexity 35

Size/Duplication

Total Lines 230
Duplicated Lines 0 %
Metric Value
dl 0
loc 230
rs 9
wmc 35
1
from contextlib import contextmanager
2
import re
3
import shutil
4
5
from coalib.bearlib.abstractions.DefaultLinterInterface import (
6
    DefaultLinterInterface)
7
from coalib.bears.LocalBear import LocalBear
8
from coalib.misc.ContextManagers import make_temp
9
from coalib.misc.Decorators import assert_right_type, enforce_signature
10
from coalib.misc.Shell import run_shell_command
11
from coalib.results.Diff import Diff
12
from coalib.results.Result import Result
13
from coalib.results.RESULT_SEVERITY import RESULT_SEVERITY
14
from coalib.settings.FunctionMetadata import FunctionMetadata
15
16
17
@enforce_signature
18
def Linter(executable: str,
19
           provides_correction: bool=False,
20
           use_stdin: bool=False,
21
           use_stderr: bool=False,
22
           config_suffix: str="",
23
           **options):
24
    """
25
    Decorator that creates a ``LocalBear`` that is able to process results from
26
    an external linter tool.
27
28
    The main functionality is achieved through the ``create_arguments()``
29
    function that constructs the command-line-arguments that get parsed to your
30
    executable.
31
32
    >>> @Linter("xlint")
33
    ... class XLintBear:
34
    ...     @staticmethod
35
    ...     def create_arguments(filename, file, config_file):
36
    ...         return "--lint", filename
37
38
    Requiring settings is possible like in ``Bear.run()`` with supplying
39
    additional keyword arguments (and if needed with defaults).
40
41
    >>> @Linter("xlint")
42
    ... class XLintBear:
43
    ...     @staticmethod
44
    ...     def create_arguments(filename,
45
    ...                          file,
46
    ...                          config_file,
47
    ...                          lintmode: str,
48
    ...                          enable_aggressive_lints: bool=False):
49
    ...         arguments = ("--lint", filename, "--mode=" + lintmode)
50
    ...         if enable_aggressive_lints:
51
    ...             arguments += ("--aggressive",)
52
    ...         return arguments
53
54
    Sometimes your tool requires an actual file that contains configuration.
55
    ``Linter`` allows you to just define the contents the configuration shall
56
    contain via ``generate_config()`` and handles everything else for you.
57
58
    >>> @Linter("xlint")
59
    ... class XLintBear:
60
    ...     @staticmethod
61
    ...     def generate_config(filename,
62
    ...                         file,
63
    ...                         lintmode,
64
    ...                         enable_aggressive_lints):
65
    ...         modestring = ("aggressive"
66
    ...                       if enable_aggressive_lints else
67
    ...                       "non-aggressive")
68
    ...         contents = ("<xlint>",
69
    ...                     "    <mode>" + lintmode + "</mode>",
70
    ...                     "    <aggressive>" + modestring + "</aggressive>",
71
    ...                     "</xlint>")
72
    ...         return "\\n".join(contents)
73
    ...
74
    ...     @staticmethod
75
    ...     def create_arguments(filename,
76
    ...                          file,
77
    ...                          config_file):
78
    ...         return "--lint", filename, "--config", config_file
79
80
    As you can see you don't need to copy additional keyword-arguments you
81
    introduced from `create_arguments()` to `generate_config()` and vice-versa.
82
    `Linter` takes care of forwarding the right arguments to the right place,
83
    so you are able to avoid signature duplication.
84
85
    For the tutorial see:
86
    http://coala.readthedocs.org/en/latest/Users/Tutorials/Linter_Bears.html
87
88
    :param executable:          The linter tool.
89
    :param provides_correction: Whether the underlying executable provides as
90
                                output the entirely corrected file instead of
91
                                issue messages.
92
    :param use_stdin:           Whether the input file is sent via stdin
93
                                instead of passing it over the
94
                                command-line-interface.
95
    :param use_stderr:          Whether stderr instead of stdout should be
96
                                grabbed for the executable output.
97
    :param config_suffix:       The suffix-string to append to the filename
98
                                of the configuration file created when
99
                                ``generate_config`` is supplied.
100
                                Useful if your executable expects getting a
101
                                specific file-type with specific file-ending
102
                                for the configuration file.
103
    :param output_regex:        The regex expression as a string that is used
104
                                to parse the output generated by the underlying
105
                                executable. It should use as many of the
106
                                following named groups (via ``(?P<name>...)``)
107
                                to provide a good result:
108
109
                                - line - The line where the issue starts.
110
                                - column - The column where the issue starts.
111
                                - end_line - The line where the issue ends.
112
                                - end_column - The column where the issue ends.
113
                                - severity - The severity of the issue.
114
                                - message - The message of the result.
115
                                - origin - The origin of the issue.
116
117
                                Needs to be provided if ``provides_correction``
118
                                is ``False``.
119
    :param severity_map:        A dict used to map a severity string (captured
120
                                from the ``output_regex`` with the named group
121
                                ``severity``) to an actual
122
                                ``coalib.results.RESULT_SEVERITY`` for a
123
                                result.
124
                                By default maps the values ``error`` to
125
                                ``RESULT_SEVERITY.MAJOR``, ``warning`` to
126
                                ``RESULT_SEVERITY.NORMAL`` and ``info`` to
127
                                ``RESULT_SEVERITY.INFO``.
128
                                Only needed if ``provides_correction`` is
129
                                ``False``.
130
                                A ``ValueError`` is raised when the named group
131
                                ``severity`` is not used inside
132
                                ``output_regex``.
133
    :param diff_severity:       The severity to use for all results if
134
                                ``provides_correction`` is set. By default this
135
                                value is
136
                                ``coalib.results.RESULT_SEVERITY.NORMAL``. The
137
                                given value needs to be defined inside
138
                                ``coalib.results.RESULT_SEVERITY``.
139
    :param diff_message:        The message-string to use for all results if
140
                                ``provides_correction`` is set. By default this
141
                                value is ``"Inconsistency found."``.
142
    :raises ValueError:         Raised when invalid options are supplied.
143
    :raises TypeError:          Raised when incompatible types are supplied.
144
                                See parameter documentations for allowed types.
145
    :return:                    A ``LocalBear`` derivation that lints code
146
                                using an external tool.
147
    """
148
    options["executable"] = executable
149
    options["provides_correction"] = provides_correction
150
    options["use_stdin"] = use_stdin
151
    options["use_stderr"] = use_stderr
152
    options["config_suffix"] = config_suffix
153
154
    allowed_options = {"executable",
155
                       "provides_correction",
156
                       "use_stdin",
157
                       "use_stderr",
158
                       "config_suffix"}
159
160
    if options["provides_correction"]:
161
        if "diff_severity" not in options:
162
            options["diff_severity"] = RESULT_SEVERITY.NORMAL
163
        else:
164
            if options["diff_severity"] not in RESULT_SEVERITY.reverse:
165
                raise TypeError("Invalid value for `diff_severity`: " +
166
                                repr(options["diff_severity"]))
167
168
        if "diff_message" not in options:
169
            options["diff_message"] = "Inconsistency found."
170
        else:
171
            assert_right_type(options["diff_message"], (str,), "diff_message")
172
173
        allowed_options |= {"diff_severity", "diff_message"}
174
    else:
175
        if "output_regex" not in options:
176
            raise ValueError("No `output_regex` specified.")
177
178
        options["output_regex"] = re.compile(options["output_regex"])
179
180
        # Don't setup severity_map if one is provided by user or if it's not
181
        # used inside the output_regex. If one is manually provided but not
182
        # used in the output_regex, throw an exception.
183
        if "severity_map" in options:
184
            if "severity" not in options["output_regex"].groupindex:
185
                raise ValueError("Provided `severity_map` but named group "
186
                                 "`severity` is not used in `output_regex`.")
187
            assert_right_type(options["severity_map"], (dict,), "severity_map")
188
        else:
189
            if "severity" in options["output_regex"].groupindex:
190
                options["severity_map"] = {"error": RESULT_SEVERITY.MAJOR,
191
                                           "warning": RESULT_SEVERITY.NORMAL,
192
                                           "info": RESULT_SEVERITY.INFO}
193
194
        allowed_options |= {"output_regex", "severity_map"}
195
196
    # Check for illegal superfluous options.
197
    superfluous_options = options.keys() - allowed_options
198
    if superfluous_options:
199
        raise ValueError("Invalid keyword argument " +
200
                         repr(superfluous_options.pop()) + " provided.")
201
202
    def create_linter(klass):
203
        # Mixin the given interface into the default interface.
204
        class LinterInterface(klass, DefaultLinterInterface):
0 ignored issues
show
Unused Code introduced by
This interface does not seem to be used anywhere.
Loading history...
205
            pass
206
207
        class Linter(LocalBear):
0 ignored issues
show
Comprehensibility Bug introduced by
Linter is re-defining a name which is already available in the outer-scope (previously defined on line 18).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
208
209
            @staticmethod
210
            def get_interface():
211
                """
212
                Returns the class that contains the interface methods (like
213
                ``create_arguments()``) this class uses.
214
215
                :return: The interface class.
216
                """
217
                return LinterInterface
218
219
            @staticmethod
220
            def get_executable():
221
                """
222
                Returns the executable of this class.
223
224
                :return: The executable name.
225
                """
226
                return options["executable"]
227
228
            @classmethod
229
            def check_prerequisites(cls):
230
                """
231
                Checks whether the linter-tool the bear uses is operational.
232
233
                :return: True if available, otherwise a string containing more
234
                         info.
235
                """
236
                if shutil.which(cls.get_executable()) is None:
237
                    return repr(cls.get_executable()) + " is not installed."
238
                else:
239
                    return True
240
241
            @classmethod
242
            def _get_create_arguments_metadata(cls):
243
                return FunctionMetadata.from_function(
244
                    cls.get_interface().create_arguments,
245
                    omit={"filename", "file", "config_file"})
246
247
            @classmethod
248
            def _get_generate_config_metadata(cls):
249
                return FunctionMetadata.from_function(
250
                    cls.get_interface().generate_config,
251
                    omit={"filename", "file"})
252
253
            @classmethod
254
            def get_non_optional_settings(cls):
255
                return cls.get_metadata().non_optional_params
256
257
            @classmethod
258
            def get_metadata(cls):
259
                return cls._merge_metadata(
260
                    cls._get_create_arguments_metadata(),
261
                    cls._get_generate_config_metadata())
262
263
            @staticmethod
264
            def _merge_metadata(metadata1, metadata2):
265
                """
266
                Merges signatures of two ``FunctionMetadata`` objects.
267
268
                Parameter descriptions (either optional or non-optional) from
269
                ``metadata1`` are overridden by ``metadata2``.
270
271
                :param metadata1: The first metadata.
272
                :param metadata2: The second metadata.
273
                :return:          A ``FunctionMetadata`` object containing the
274
                                  merged signature of ``metadata1`` and
275
                                  ``metadata2``.
276
                """
277
                merged_optional_params = metadata1.optional_params
278
                merged_optional_params.update(metadata2.optional_params)
279
                merged_non_optional_params = metadata1.non_optional_params
280
                merged_non_optional_params.update(metadata2.non_optional_params)
281
282
                return FunctionMetadata(
283
                    "<Merged signature of {} and {}>".format(
284
                        repr(metadata1.name),
285
                        repr(metadata2.name)),
286
                    "{}:\n{}\n{}:\n{}".format(
287
                        metadata1.name,
288
                        metadata1.desc,
289
                        metadata2.name,
290
                        metadata2.desc),
291
                    "{}:\n{}\n{}:\n{}".format(
292
                        metadata1.name,
293
                        metadata1.retval_desc,
294
                        metadata2.name,
295
                        metadata2.retval_desc),
296
                    merged_non_optional_params,
297
                    merged_optional_params,
298
                    metadata1.omit | metadata2.omit)
299
300
            @classmethod
301
            def _execute_command(cls, args, stdin=None):
302
                """
303
                Executes the underlying tool with the given arguments.
304
305
                :param args:  The argument sequence to pass to the executable.
306
                :param stdin: Input to send to the opened process as stdin.
307
                :return:      A tuple with ``(stdout, stderr)``.
308
                """
309
                return run_shell_command(
310
                    (cls.get_executable(),) + tuple(args),
311
                    stdin=stdin)
312
313
            def _convert_output_regex_match_to_result(self, match, filename):
314
                """
315
                Converts the matched named-groups of ``output_regex`` to an
316
                actual ``Result``.
317
318
                :param match:    The regex match object.
319
                :param filename: The name of the file this match belongs to.
320
                """
321
                # Pre process the groups
322
                groups = match.groupdict()
323
324
                if "severity_map" in options:
325
                    # `severity_map` is only contained inside `options` when
326
                    # it's actually used.
327
                    groups["severity"] = (
328
                        options["severity_map"][groups["severity"]])
329
330
                for variable in ("line", "column", "end_line", "end_column"):
331
                    if variable in groups and groups[variable]:
332
                        groups[variable] = int(groups[variable])
333
334
                if "origin" in groups:
335
                    groups["origin"] = "{} ({})".format(
336
                        str(klass.__name__),
337
                        str(groups["origin"]))
338
339
                # Construct the result.
340
                return Result.from_values(
341
                    origin=groups.get("origin", self),
342
                    message=groups.get("message", ""),
343
                    file=filename,
344
                    severity=int(groups.get("severity",
345
                                            RESULT_SEVERITY.NORMAL)),
346
                    line=groups.get("line", None),
347
                    column=groups.get("column", None),
348
                    end_line=groups.get("end_line", None),
349
                    end_column=groups.get("end_column", None))
350
351
            if options["provides_correction"]:
352
                def _process_output(self, output, filename, file):
353
                    for diff in Diff.from_string_arrays(
354
                            file,
355
                            output.splitlines(keepends=True)).split_diff():
356
                        yield Result(self,
357
                                     options["diff_message"],
358
                                     affected_code=(diff.range(filename),),
359
                                     diffs={filename: diff},
360
                                     severity=options["diff_severity"])
361
            else:
362
                def _process_output(self, output, filename, file):
363
                    for match in options["output_regex"].finditer(output):
364
                        yield self._convert_output_regex_match_to_result(
365
                            match, filename)
366
367
            if options["use_stderr"]:
368
                @staticmethod
369
                def _grab_output(stdout, stderr):
370
                    return stderr
371
            else:
372
                @staticmethod
373
                def _grab_output(stdout, stderr):
374
                    return stdout
375
376
            if options["use_stdin"]:
377
                @staticmethod
378
                def _pass_file_as_stdin_if_needed(file):
379
                    return "".join(file)
380
            else:
381
                @staticmethod
382
                def _pass_file_as_stdin_if_needed(file):
383
                    return None
384
385
            @classmethod
386
            @contextmanager
387
            def _create_config(cls, filename, file, **kwargs):
388
                """
389
                Provides a context-manager that creates the config file if the
390
                user provides one and cleans it up when done with linting.
391
392
                :param filename: The filename of the file.
393
                :param file:     The file contents.
394
                :param kwargs:   Section settings passed from ``run()``.
395
                :return:         A context-manager handling the config-file.
396
                """
397
                content = cls.get_interface().generate_config(filename,
398
                                                              file,
399
                                                              **kwargs)
400
                if content is None:
401
                    yield None
402
                else:
403
                    tmp_suffix = options["config_suffix"]
404
                    with make_temp(suffix=tmp_suffix) as config_file:
405
                        with open(config_file, mode="w") as fl:
406
                            fl.write(content)
407
                        yield config_file
408
409
            def run(self, filename, file, **kwargs):
410
                # Get the **kwargs params to forward to `generate_config()`
411
                # (from `_create_config()`).
412
                generate_config_kwargs = {
413
                    key: kwargs[key]
414
                    for key in self._get_generate_config_metadata()
415
                    .non_optional_params.keys()}
416
417
                with self._create_config(
418
                        filename,
419
                        file,
420
                        **generate_config_kwargs) as config_file:
421
422
                    # And now retrieve the **kwargs for `create_arguments()`.
423
                    create_arguments_kwargs = {
424
                        key: kwargs[key]
425
                        for key in self._get_create_arguments_metadata()
426
                        .non_optional_params.keys()}
427
428
                    stdout, stderr = self._execute_command(
429
                        self.get_interface().create_arguments(
430
                            filename,
431
                            file,
432
                            config_file,
433
                            **create_arguments_kwargs),
434
                        stdin=self._pass_file_as_stdin_if_needed(file))
435
                    output = self._grab_output(stdout, stderr)
436
                    return self._process_output(output, filename, file)
437
438
        return Linter
439
440
    return create_linter
441