yala/base.py
"""Parser module to abstract different parsers."""
import logging
from abc import ABCMeta, abstractmethod
from pathlib import Path
LOG = logging.getLogger(__name__)
class LinterOutput:
"""A one-line linter result. It can be sorted and printed as string."""
# We only override magic methods.
def __init__(self, linter_name, path, msg, line_nr=None, col=None):
"""Optionally set all attributes.
Args:
path (str): Relative file path.
line (int): Line number.
msg (str): Explanation of what is wrong.
col (int): Column where the problem begins.
"""
# Set all attributes in the constructor for convenience.
# pylint: disable=too-many-arguments
if line_nr:
line_nr = int(line_nr)
if col:
col = int(col)
self._linter_name = linter_name
self.path = path
self.line_nr = line_nr
self.msg = msg
self.col = col
def __str__(self):
"""Output shown to the user."""
return (
f"{self.path}|{self.line_nr}:{self.col}|{self.msg} "
f"[{self._linter_name}]"
)
def _cmp_key(self, obj=None):
"""Comparison key for sorting results from all linters.
The sort should group files and lines from different linters to make it
easier for refactoring.
"""
if not obj:
obj = self
line_nr = int(obj.line_nr) if obj.line_nr else 0
col = int(obj.col) if obj.col else 0
return (obj.path, line_nr, col, obj.msg)
def __lt__(self, other):
"""Use ``_cmp_key`` to compare two lines."""
if isinstance(other, type(self)):
return self._cmp_key() < self._cmp_key(other)
return super().__lt__(other)
class Linter(metaclass=ABCMeta):
"""Linter implementations should inherit from this class."""
# Most methods are for child class only, not public.
#: dict: Configuration for a specific linter
config = None
#: str: Name of this linter. Recommended to be the same as its command.
name = ""
@property
def command(self):
"""Command to execute. Defaults to :attr:`name`.
The options in config files are appended in
:meth:`command_with_options`.
"""
return self.name
@property
def command_with_options(self):
"""Add arguments from config to :attr:`command`."""
if "args" in self.config:
return " ".join((self.command, self.config["args"]))
return self.command
@abstractmethod
def parse(self, stdout_lines, stderr_lines):
"""Parse linter output and return results.
Args:
stdout_lines (iterable): Linter's standard output lines.
stderr_lines (iterable): Linter's standard error lines.
Returns:
iterable of LinterOutput: Linter results to print to stdout.
iterable of str: Lines to print to stderr.
"""
def _get_relative_path(self, full_path):
"""Return the relative path from current path."""
try:
rel_path = Path(full_path).relative_to(Path().absolute())
except ValueError:
LOG.error(
"%s: Couldn't find relative path of '%s' from '%s'.",
self.name,
full_path,
Path().absolute(),
)
return full_path
return str(rel_path)
def _parse_by_pattern(self, lines, pattern):
"""Match pattern line by line and return LinterOutputs.
Use ``_create_output_from_match`` to convert pattern match groups to
LinterOutput instances.
Args:
lines (iterable): Output lines to be parsed.
pattern: Compiled pattern to match against lines.
result_fn (function): Receive results of one match and return a
LinterOutput.
Return:
generator: LinterOutput instances.
"""
buffer = "" # lines is an iterable, but there may be multiline matches
for line in lines:
buffer += line
match = pattern.match(buffer)
if match:
buffer = "" # clear buffer after a match
params = match.groupdict()
if not params:
params = match.groups()
yield self._create_output_from_match(params)
else:
buffer += "\n" # keep lines separated by a newline
def _create_output_from_match(self, match_result):
"""Create LinterOutput instance from pattern match results.
Args:
match: Pattern match.
"""
if isinstance(match_result, dict):
return LinterOutput(self.name, **match_result)
return LinterOutput(self.name, *match_result)