Failed Conditions
Pull Request — master (#1990)
by Mischa
02:35 queued 43s
created

LinterBase.create_arguments()   A

Complexity

Conditions 1

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
c 1
b 0
f 0
dl 0
loc 19
rs 9.4285
1
from contextlib import contextmanager
2
from functools import partial
3
import inspect
4
from itertools import compress
5
import re
6
import shutil
7
from subprocess import check_call, CalledProcessError, DEVNULL
8
from types import MappingProxyType
9
10
from coalib.bears.LocalBear import LocalBear
11
from coalib.misc.ContextManagers import make_temp
12
from coalib.misc.Decorators import assert_right_type, enforce_signature
13
from coalib.misc.Future import partialmethod
14
from coalib.misc.Shell import run_shell_command
15
from coalib.results.Diff import Diff
16
from coalib.results.Result import Result
17
from coalib.results.RESULT_SEVERITY import RESULT_SEVERITY
18
from coalib.settings.FunctionMetadata import FunctionMetadata
19
20
21
def _prepare_options(options):
22
    """
23
    Prepares options for ``Linter`` for a given options dict in-place.
24
25
    :param options:
26
        The options dict that contains user/developer inputs.
27
    """
28
    allowed_options = {"executable",
29
                       "output_format",
30
                       "use_stdin",
31
                       "use_stdout",
32
                       "use_stderr",
33
                       "config_suffix",
34
                       "prerequisite_check_command"}
35
36
    if not options["use_stdout"] and not options["use_stderr"]:
37
        raise ValueError("No output streams provided at all.")
38
39
    if options["output_format"] == "corrected":
40
        if (
41
                "diff_severity" in options and
42
                options["diff_severity"] not in RESULT_SEVERITY.reverse):
43
            raise TypeError("Invalid value for `diff_severity`: " +
44
                            repr(options["diff_severity"]))
45
46
        if "diff_message" in options:
47
            assert_right_type(options["diff_message"], str, "diff_message")
48
49
        allowed_options |= {"diff_severity", "diff_message"}
50
    elif options["output_format"] == "regex":
51
        if "output_regex" not in options:
52
            raise ValueError("`output_regex` needed when specified "
53
                             "output-format 'regex'.")
54
55
        options["output_regex"] = re.compile(options["output_regex"])
56
57
        # Don't setup severity_map if one is provided by user or if it's not
58
        # used inside the output_regex. If one is manually provided but not
59
        # used in the output_regex, throw an exception.
60
        if "severity_map" in options:
61
            if "severity" not in options["output_regex"].groupindex:
62
                raise ValueError("Provided `severity_map` but named group "
63
                                 "`severity` is not used in `output_regex`.")
64
            assert_right_type(options["severity_map"], dict, "severity_map")
65
66
            for key, value in options["severity_map"].items():
67
                assert_right_type(key, str, "severity_map key")
68
69
                try:
70
                    assert_right_type(value, int, "<severity_map dict-value>")
71
                except TypeError:
72
                    raise TypeError(
73
                        "The value {} for key {} inside given severity-map is "
74
                        "no valid severity value.".format(repr(value),
75
                                                          repr(key)))
76
77
                if value not in RESULT_SEVERITY.reverse:
78
                    raise TypeError(
79
                        "Invalid severity value {} for key {} inside given "
80
                        "severity-map.".format(repr(value), repr(key)))
81
82
            # Auto-convert keys to lower-case. This creates automatically a new
83
            # dict which prevents runtime-modifications.
84
            options["severity_map"] = {
85
                key.lower(): value
86
                for key, value in options["severity_map"].items()}
87
88
        allowed_options |= {"output_regex", "severity_map"}
89
    elif options["output_format"] is not None:
90
        raise ValueError("Invalid `output_format` specified.")
91
92
    if options["prerequisite_check_command"]:
93
        if "prerequisite_check_fail_message" in options:
94
            assert_right_type(options["prerequisite_check_fail_message"],
95
                              str,
96
                              "prerequisite_check_fail_message")
97
        else:
98
            options["prerequisite_check_fail_message"] = (
99
                "Prerequisite check failed.")
100
101
        allowed_options.add("prerequisite_check_fail_message")
102
103
    # Check for illegal superfluous options.
104
    superfluous_options = options.keys() - allowed_options
105
    if superfluous_options:
106
        raise ValueError(
107
            "Invalid keyword arguments provided: " +
108
            ", ".join(repr(s) for s in sorted(superfluous_options)))
109
110
111
def _create_linter(klass, options):
112
    class LinterMeta(type):
113
114
        def __repr__(cls):
115
            return "<{} linter class (wrapping {})>".format(
116
                cls.__name__, repr(options["executable"]))
117
118
    class LinterBase(LocalBear, metaclass=LinterMeta):
0 ignored issues
show
Unused Code introduced by
This abstract class does not seem to be used anywhere.
Loading history...
119
120
        @staticmethod
121
        def generate_config(filename, file):
122
            """
123
            Generates the content of a config-file the linter-tool might
124
            need.
125
126
            The contents generated from this function are written to a
127
            temporary file and the path is provided inside
128
            ``create_arguments()``.
129
130
            By default no configuration is generated.
131
132
            You can provide additional keyword arguments and defaults. These
133
            will be interpreted as required settings that need to be provided
134
            through a coafile-section.
135
136
            :param filename:
137
                The name of the file currently processed.
138
            :param file:
139
                The contents of the file currently processed.
140
            :return:
141
                The config-file-contents as a string or ``None``.
142
            """
143
            return None
144
145
        @staticmethod
146
        def create_arguments(filename, file, config_file):
147
            """
148
            Creates the arguments for the linter.
149
150
            You can provide additional keyword arguments and defaults. These
151
            will be interpreted as required settings that need to be provided
152
            through a coafile-section.
153
154
            :param filename:
155
                The name of the file the linter-tool shall process.
156
            :param file:
157
                The contents of the file.
158
            :param config_file:
159
                The path of the config-file if used. ``None`` if unused.
160
            :return:
161
                A sequence of arguments to feed the linter-tool with.
162
            """
163
            raise NotImplementedError
164
165
        @staticmethod
166
        def get_executable():
167
            """
168
            Returns the executable of this class.
169
170
            :return:
171
                The executable name.
172
            """
173
            return options["executable"]
174
175
        @classmethod
176
        def check_prerequisites(cls):
177
            """
178
            Checks whether the linter-tool the bear uses is operational.
179
180
            :return:
181
                True if operational, otherwise a string containing more info.
182
            """
183
            if shutil.which(cls.get_executable()) is None:
184
                return repr(cls.get_executable()) + " is not installed."
185
            else:
186
                if options["prerequisite_check_command"]:
187
                    try:
188
                        check_call(options["prerequisite_check_command"],
189
                                   stdout=DEVNULL,
190
                                   stderr=DEVNULL)
191
                        return True
192
                    except (OSError, CalledProcessError):
193
                        return options["prerequisite_check_fail_message"]
194
                return True
195
196
        @classmethod
197
        def _get_create_arguments_metadata(cls):
198
            return FunctionMetadata.from_function(
199
                cls.create_arguments,
200
                omit={"filename", "file", "config_file"})
201
202
        @classmethod
203
        def _get_generate_config_metadata(cls):
204
            return FunctionMetadata.from_function(
205
                cls.generate_config,
206
                omit={"filename", "file"})
207
208
        @classmethod
209
        def _get_process_output_metadata(cls):
210
            return FunctionMetadata.from_function(
211
                cls.process_output,
212
                omit={"self", "output", "filename", "file"})
213
214
        @classmethod
215
        def get_metadata(cls):
216
            merged_metadata = FunctionMetadata.merge(
217
                cls._get_process_output_metadata(),
218
                cls._get_generate_config_metadata(),
219
                cls._get_create_arguments_metadata())
220
            merged_metadata.desc = inspect.getdoc(cls)
221
            return merged_metadata
222
223
        def _convert_output_regex_match_to_result(self,
224
                                                  match,
225
                                                  filename,
226
                                                  severity_map):
227
            """
228
            Converts the matched named-groups of ``output_regex`` to an actual
229
            ``Result``.
230
231
            :param match:
232
                The regex match object.
233
            :param filename:
234
                The name of the file this match belongs to.
235
            :param severity_map:
236
                The dict to use to map the severity-match to an actual
237
                ``RESULT_SEVERITY``.
238
            """
239
            # Pre process the groups
240
            groups = match.groupdict()
241
242
            try:
243
                groups["severity"] = severity_map[
244
                    groups["severity"].lower()]
245
            except KeyError:
246
                self.warn(
247
                    repr(groups["severity"]) + " not found in severity-map. "
248
                    "Assuming `RESULT_SEVERITY.NORMAL`.")
249
                groups["severity"] = RESULT_SEVERITY.NORMAL
250
251
            for variable in ("line", "column", "end_line", "end_column"):
252
                groups[variable] = (None
253
                                    if groups.get(variable, "") == "" else
254
                                    int(groups[variable]))
255
256
            if "origin" in groups:
257
                groups["origin"] = "{} ({})".format(klass.__name__,
258
                                                    groups["origin"])
259
260
            # Construct the result.
261
            return Result.from_values(
262
                origin=groups.get("origin", self),
263
                message=groups.get("message", ""),
264
                file=filename,
265
                severity=groups["severity"],
266
                line=groups["line"],
267
                column=groups["column"],
268
                end_line=groups["end_line"],
269
                end_column=groups["end_column"])
270
271
        def process_output_corrected(self,
272
                                     output,
273
                                     filename,
274
                                     file,
275
                                     diff_severity=RESULT_SEVERITY.NORMAL,
276
                                     diff_message="Inconsistency found."):
277
            """
278
            Processes the executable's output as a corrected file.
279
280
            :param output:
281
                The output of the program. This can be either a single
282
                string or a sequence of strings.
283
            :param filename:
284
                The filename of the file currently being corrected.
285
            :param file:
286
                The contents of the file currently being corrected.
287
            :param diff_severity:
288
                The severity to use for generating results.
289
            :param diff_message:
290
                The message to use for generating results.
291
            :return:
292
                An iterator returning results containing patches for the
293
                file to correct.
294
            """
295
            if isinstance(output, str):
296
                output = (output,)
297
298
            for string in output:
299
                for diff in Diff.from_string_arrays(
300
                        file,
301
                        string.splitlines(keepends=True)).split_diff():
302
                    yield Result(self,
303
                                 diff_message,
304
                                 affected_code=(diff.range(filename),),
305
                                 diffs={filename: diff},
306
                                 severity=diff_severity)
307
308
        def process_output_regex(
309
                self, output, filename, file, output_regex,
310
                severity_map=MappingProxyType({
311
                    "error": RESULT_SEVERITY.MAJOR,
312
                    "warning": RESULT_SEVERITY.NORMAL,
313
                    "warn": RESULT_SEVERITY.NORMAL,
314
                    "info": RESULT_SEVERITY.INFO})):
315
            """
316
            Processes the executable's output using a regex.
317
318
            :param output:
319
                The output of the program. This can be either a single
320
                string or a sequence of strings.
321
            :param filename:
322
                The filename of the file currently being corrected.
323
            :param file:
324
                The contents of the file currently being corrected.
325
            :param output_regex:
326
                The regex to parse the output with. It should use as many
327
                of the following named groups (via ``(?P<name>...)``) to
328
                provide a good result:
329
330
                - line - The line where the issue starts.
331
                - column - The column where the issue starts.
332
                - end_line - The line where the issue ends.
333
                - end_column - The column where the issue ends.
334
                - severity - The severity of the issue.
335
                - message - The message of the result.
336
                - origin - The origin of the issue.
337
338
                The groups ``line``, ``column``, ``end_line`` and
339
                ``end_column`` don't have to match numbers only, they can
340
                also match nothing, the generated ``Result`` is filled
341
                automatically with ``None`` then for the appropriate
342
                properties.
343
            :param severity_map:
344
                A dict used to map a severity string (captured from the
345
                ``output_regex`` with the named group ``severity``) to an
346
                actual ``coalib.results.RESULT_SEVERITY`` for a result.
347
            :return:
348
                An iterator returning results.
349
            """
350
            if isinstance(output, str):
351
                output = (output,)
352
353
            for string in output:
354
                for match in re.finditer(output_regex, string):
355
                    yield self._convert_output_regex_match_to_result(
356
                        match, filename, severity_map=severity_map)
357
358
        if options["output_format"] is None:
359
            # Check if user supplied a `process_output` override.
360
            if not callable(getattr(klass, "process_output", None)):
361
                raise ValueError("`process_output` not provided by given "
362
                                 "class {}.".format(repr(klass.__name__)))
363
                # No need to assign to `process_output` here, the class mixing
364
                # below automatically does that.
365
        else:
366
            # Prevent people from accidentally defining `process_output`
367
            # manually, as this would implicitly override the internally
368
            # set-up `process_output`.
369
            if hasattr(klass, "process_output"):
370
                raise ValueError("Found `process_output` already defined "
371
                                 "by class {}, but {} output-format is "
372
                                 "specified.".format(
373
                                     repr(klass.__name__),
374
                                     repr(options["output_format"])))
375
376
            if options["output_format"] == "corrected":
377
                process_output_args = {
378
                    key: options[key]
379
                    for key in ("diff_message", "diff_severity")
380
                    if key in options}
381
382
                process_output = partialmethod(
383
                    process_output_corrected, **process_output_args)
384
385
            else:
386
                assert options["output_format"] == "regex"
387
388
                process_output_args = {
389
                    key: options[key]
390
                    for key in ("output_regex", "severity_map")
391
                    if key in options}
392
393
                process_output = partialmethod(
394
                    process_output_regex, **process_output_args)
395
396
        @classmethod
397
        @contextmanager
398
        def _create_config(cls, filename, file, **kwargs):
399
            """
400
            Provides a context-manager that creates the config file if the
401
            user provides one and cleans it up when done with linting.
402
403
            :param filename:
404
                The filename of the file.
405
            :param file:
406
                The file contents.
407
            :param kwargs:
408
                Section settings passed from ``run()``.
409
            :return:
410
                A context-manager handling the config-file.
411
            """
412
            content = cls.generate_config(filename, file, **kwargs)
413
            if content is None:
414
                yield None
415
            else:
416
                with make_temp(
417
                        suffix=options["config_suffix"]) as config_file:
418
                    with open(config_file, mode="w") as fl:
419
                        fl.write(content)
420
                    yield config_file
421
422
        def run(self, filename, file, **kwargs):
423
            # Get the **kwargs params to forward to `generate_config()`
424
            # (from `_create_config()`).
425
            generate_config_kwargs = FunctionMetadata.filter_parameters(
426
                self._get_generate_config_metadata(), kwargs)
427
428
            with self._create_config(
429
                    filename,
430
                    file,
431
                    **generate_config_kwargs) as config_file:
432
                # And now retrieve the **kwargs for `create_arguments()`.
433
                create_arguments_kwargs = (
434
                    FunctionMetadata.filter_parameters(
435
                        self._get_create_arguments_metadata(), kwargs))
436
437
                output = run_shell_command(
438
                    (self.get_executable(),) + tuple(self.create_arguments(
439
                        filename, file, config_file,
440
                        **create_arguments_kwargs)),
441
                    stdin="".join(file) if options["use_stdin"] else None)
442
443
                output = tuple(compress(
444
                    output,
445
                    (options["use_stdout"], options["use_stderr"])))
446
                if len(output) == 1:
447
                    output = output[0]
448
449
                process_output_kwargs = FunctionMetadata.filter_parameters(
450
                    self._get_process_output_metadata(), kwargs)
451
                return self.process_output(output, filename, file,
452
                                           **process_output_kwargs)
453
454
        def __repr__(self):
455
            return "<{} linter object (wrapping {}) at {}>".format(
456
                type(self).__name__, repr(self.get_executable()), hex(id(self)))
457
458
    # Mixin the linter into the user-defined interface, otherwise
459
    # `create_arguments` and other methods would be overridden by the
460
    # default version.
461
    return type(klass.__name__, (klass, LinterBase), {})
462
463
464
@enforce_signature
465
def linter(executable: str,
466
           use_stdin: bool=False,
467
           use_stdout: bool=True,
468
           use_stderr: bool=False,
469
           config_suffix: str="",
470
           prerequisite_check_command: tuple=(),
471
           output_format: (str, None)=None,
472
           **options):
473
    """
474
    Decorator that creates a ``LocalBear`` that is able to process results from
475
    an external linter tool.
476
477
    The main functionality is achieved through the ``create_arguments()``
478
    function that constructs the command-line-arguments that get parsed to your
479
    executable.
480
481
    >>> @linter("xlint", output_format="regex", output_regex="...")
482
    ... class XLintBear:
483
    ...     @staticmethod
484
    ...     def create_arguments(filename, file, config_file):
485
    ...         return "--lint", filename
486
487
    Requiring settings is possible like in ``Bear.run()`` with supplying
488
    additional keyword arguments (and if needed with defaults).
489
490
    >>> @linter("xlint", output_format="regex", output_regex="...")
491
    ... class XLintBear:
492
    ...     @staticmethod
493
    ...     def create_arguments(filename,
494
    ...                          file,
495
    ...                          config_file,
496
    ...                          lintmode: str,
497
    ...                          enable_aggressive_lints: bool=False):
498
    ...         arguments = ("--lint", filename, "--mode=" + lintmode)
499
    ...         if enable_aggressive_lints:
500
    ...             arguments += ("--aggressive",)
501
    ...         return arguments
502
503
    Sometimes your tool requires an actual file that contains configuration.
504
    ``Linter`` allows you to just define the contents the configuration shall
505
    contain via ``generate_config()`` and handles everything else for you.
506
507
    >>> @linter("xlint", output_format="regex", output_regex="...")
508
    ... class XLintBear:
509
    ...     @staticmethod
510
    ...     def generate_config(filename,
511
    ...                         file,
512
    ...                         lintmode,
513
    ...                         enable_aggressive_lints):
514
    ...         modestring = ("aggressive"
515
    ...                       if enable_aggressive_lints else
516
    ...                       "non-aggressive")
517
    ...         contents = ("<xlint>",
518
    ...                     "    <mode>" + lintmode + "</mode>",
519
    ...                     "    <aggressive>" + modestring + "</aggressive>",
520
    ...                     "</xlint>")
521
    ...         return "\\n".join(contents)
522
    ...
523
    ...     @staticmethod
524
    ...     def create_arguments(filename,
525
    ...                          file,
526
    ...                          config_file):
527
    ...         return "--lint", filename, "--config", config_file
528
529
    As you can see you don't need to copy additional keyword-arguments you
530
    introduced from ``create_arguments()`` to ``generate_config()`` and
531
    vice-versa. ``Linter`` takes care of forwarding the right arguments to the
532
    right place, so you are able to avoid signature duplication.
533
534
    If you override ``process_output``, you have the same feature like above
535
    (auto-forwarding of the right arguments defined in your function
536
    signature).
537
538
    Note when overriding ``process_output``: Providing a single output stream
539
    (via ``use_stdout`` or ``use_stderr``) puts the according string attained
540
    from the stream into parameter ``output``, providing both output streams
541
    inputs a tuple with ``(stdout, stderr)``. Providing ``use_stdout=False``
542
    and ``use_stderr=False`` raises a ``ValueError``. By default ``use_stdout``
543
    is ``True`` and ``use_stderr`` is ``False``.
544
545
    Documentation:
546
    Bear description shall be provided at class level.
547
    If you document your additional parameters inside ``create_arguments``,
548
    ``generate_config`` and ``process_output``, beware that conflicting
549
    documentation between them may be overridden. Document duplicated
550
    parameters inside ``create_arguments`` first, then in ``generate_config``
551
    and after that inside ``process_output``.
552
553
    For the tutorial see:
554
    http://coala.readthedocs.org/en/latest/Users/Tutorials/Linter_Bears.html
555
556
    :param executable:
557
        The linter tool.
558
    :param use_stdin:
559
        Whether the input file is sent via stdin instead of passing it over the
560
        command-line-interface.
561
    :param use_stdout:
562
        Whether to use the stdout output stream.
563
    :param use_stderr:
564
        Whether to use the stderr output stream.
565
    :param config_suffix:
566
        The suffix-string to append to the filename of the configuration file
567
        created when ``generate_config`` is supplied. Useful if your executable
568
        expects getting a specific file-type with specific file-ending for the
569
        configuration file.
570
    :param prerequisite_check_command:
571
        A custom command to check for when ``check_prerequisites`` gets
572
        invoked (via ``subprocess.check_call()``). Must be an ``Iterable``.
573
    :param prerequisite_check_fail_message:
574
        A custom command to check for when ``check_prerequisites`` gets
575
        invoked. Must be provided only together with
576
        ``prerequisite_check_command``.
577
    :param output_format:
578
        The output format of the underlying executable. Valid values are
579
580
        - ``None``: Define your own format by overriding ``process_output``.
581
          Overriding ``process_output`` is then mandatory, not specifying it
582
          raises a ``ValueError``.
583
        - ``'regex'``: Parse output using a regex. See parameter
584
          ``output_regex``.
585
        - ``'corrected'``: The output is the corrected of the given file. Diffs
586
          are then generated to supply patches for results.
587
588
        Passing something else raises a ``ValueError``.
589
    :param output_regex:
590
        The regex expression as a string that is used to parse the output
591
        generated by the underlying executable. It should use as many of the
592
        following named groups (via ``(?P<name>...)``) to provide a good
593
        result:
594
595
        - line - The line where the issue starts.
596
        - column - The column where the issue starts.
597
        - end_line - The line where the issue ends.
598
        - end_column - The column where the issue ends.
599
        - severity - The severity of the issue.
600
        - message - The message of the result.
601
        - origin - The origin of the issue.
602
603
        The groups ``line``, ``column``, ``end_line`` and ``end_column`` don't
604
        have to match numbers only, they can also match nothing, the generated
605
        ``Result`` is filled automatically with ``None`` then for the
606
        appropriate properties.
607
608
        Needs to be provided if ``output_format`` is ``'regex'``.
609
    :param severity_map:
610
        A dict used to map a severity string (captured from the
611
        ``output_regex`` with the named group ``severity``) to an actual
612
        ``coalib.results.RESULT_SEVERITY`` for a result. Severity strings are
613
        mapped **case-insensitive**!
614
615
        - ``RESULT_SEVERITY.MAJOR``: Mapped by ``error``.
616
        - ``RESULT_SEVERITY.NORMAL``: Mapped by ``warning`` or ``warn``.
617
        - ``RESULT_SEVERITY.MINOR``: Mapped by ``info``.
618
619
        A ``ValueError`` is raised when the named group ``severity`` is not
620
        used inside ``output_regex`` and this parameter is given.
621
    :param diff_severity:
622
        The severity to use for all results if ``output_format`` is
623
        ``'corrected'``. By default this value is
624
        ``coalib.results.RESULT_SEVERITY.NORMAL``. The given value needs to be
625
        defined inside ``coalib.results.RESULT_SEVERITY``.
626
    :param diff_message:
627
        The message-string to use for all results if ``output_format`` is
628
        ``'corrected'``. By default this value is ``"Inconsistency found."``.
629
    :raises ValueError:
630
        Raised when invalid options are supplied.
631
    :raises TypeError:
632
        Raised when incompatible types are supplied.
633
        See parameter documentations for allowed types.
634
    :return:
635
        A ``LocalBear`` derivation that lints code using an external tool.
636
    """
637
    options["executable"] = executable
638
    options["output_format"] = output_format
639
    options["use_stdin"] = use_stdin
640
    options["use_stdout"] = use_stdout
641
    options["use_stderr"] = use_stderr
642
    options["config_suffix"] = config_suffix
643
    options["prerequisite_check_command"] = prerequisite_check_command
644
645
    _prepare_options(options)
646
647
    return partial(_create_linter, options=options)
648