Source code for galaxy.tool_util.lint

"""This modules contains the functions that drive the tool linting framework.

LintContext: a container for LintMessages
LintMessage: the actual message and level

The idea is to define a LintContext and to apply a linting function ``foo`` on a
``target``. The ``level`` (defined by ``LintLevel``) determines which linting
messages are shown::


    lint_ctx = LintContext(level) # level is the reporting level
    lint_ctx.lint(..., lint_func = foo, lint_target = target, ...)

The ``lint`` function essentially calls ``foo(target, self)``. Hence
the function ``foo`` must have two parameters:

1. the object to lint
2. the lint context

In ``foo`` the lint context can be used to add LintMessages to the lint context
by using its ``valid``, ``info``, ``warn``, and ``error`` functions::


    lint_foo(target, lint_ctx):
        lint_ctx.error("target is screwed")

Calling ``lint`` prints out the messages emmited by the linter
function immediately. Which messages are shown can be determined with the
``level`` argument of the ``LintContext`` constructor. If set to ``SILENT``,
no messages will be printed.

For special lint targets it might be useful to have additional information
in the lint messages. This can be achieved by subclassing ``LintMessage``.
See for instance ``XMLLintMessageLine`` which has an additional argument
``node`` in its constructor which is used to determine the line and filename in
an XML document that caused the message.

In order to use this.

- the lint context needs to be initialized with the additional parameter
  ``lint_message_class=XMLLintMessageLine``
- the additional parameter needs to be added as well to calls adding messages
  to the lint context, e.g. ``lint_ctx.error("some message", node=X)``. Note
  that the additional properties must be given as keyword arguments.
"""

import inspect
from abc import (
    ABC,
    abstractmethod,
)
from enum import IntEnum
from typing import (
    Callable,
    List,
    Optional,
    Type,
    TYPE_CHECKING,
    TypeVar,
    Union,
)

import galaxy.tool_util.linters
from galaxy.tool_util.parser import get_tool_source
from galaxy.util import (
    Element,
    submodules,
)

if TYPE_CHECKING:
    from galaxy.tool_util.parser.interface import ToolSource


[docs]class LintLevel(IntEnum): SILENT = 5 ERROR = 4 WARN = 3 INFO = 2 VALID = 1 ALL = 0
[docs]class Linter(ABC): """ a linter. needs to define a lint method and the code property. optionally a fix method can be given """
[docs] @classmethod @abstractmethod def lint(cls, tool_source: "ToolSource", lint_ctx: "LintContext"): """ should add at most one message to the lint context """ pass
[docs] @classmethod def name(cls) -> str: """ get the linter name """ return cls.__name__
[docs] @classmethod def list_linters(cls) -> List[str]: """ list the names of all linter derived from Linter """ submodules.import_submodules(galaxy.tool_util.linters) return [s.__name__ for s in cls.__subclasses__()]
list_listers: Callable[[], List[str]] # deprecated alias
# Define the `list_listers` alias outside of the `Linter` class so that # @classmethod's change to `list_linters`s signature has taken effect and mypy # doesn't report an [assignment] error Linter.list_listers = Linter.list_linters
[docs]class LintMessage: """ a message from the linter """
[docs] def __init__(self, level: str, message: str, linter: Optional[str] = None, **kwargs): self.level = level self.message = message self.linter = linter
def __eq__(self, other) -> bool: """ add equal operator to easy lookup of a message in a List[LintMessage] which is useful in tests. If the other object is a string, it is loosely checked if the string is contained in the message. """ if isinstance(other, str): return other in self.message if isinstance(other, LintMessage): return self.message == other.message return False def __str__(self) -> str: if self.linter: linter = f" ({self.linter})" else: linter = "" return f".. {self.level.upper()}{linter}: {self.message}" def __repr__(self) -> str: return f"LintMessage({self.message})"
[docs]class XMLLintMessageLine(LintMessage):
[docs] def __init__(self, level: str, message: str, linter: Optional[str] = None, node: Optional[Element] = None): super().__init__(level, message, linter) self.line = None if node is not None: self.line = node.sourceline
def __str__(self) -> str: rval = super().__str__() if self.line is not None: rval += " (" rval += str(self.line) rval += ")" return rval
[docs]class XMLLintMessageXPath(LintMessage):
[docs] def __init__(self, level: str, message: str, linter: Optional[str] = None, node: Optional[Element] = None): super().__init__(level, message, linter) self.xpath = None if node is not None: tool_xml = node.getroottree() self.xpath = tool_xml.getpath(node)
def __str__(self) -> str: rval = super().__str__() if self.xpath is not None: rval += f" [{self.xpath}]" return rval
LintTargetType = TypeVar("LintTargetType") # TODO: Nothing inherently tool-y about LintContext and in fact # it is reused for repositories in planemo. Therefore, it should probably # be moved to galaxy.util.lint.
[docs]class LintContext: skip_types: List[str] level: LintLevel lint_message_class: Type[LintMessage] object_name: Optional[str] message_list: List[LintMessage]
[docs] def __init__( self, level: Union[LintLevel, str], lint_message_class: Type[LintMessage] = LintMessage, skip_types: Optional[List[str]] = None, object_name: Optional[str] = None, ): self.skip_types = skip_types or [] if isinstance(level, str): self.level = LintLevel[level.upper()] else: self.level = level self.lint_message_class = lint_message_class self.object_name = object_name self.message_list = []
@property def found_errors(self) -> bool: return len(self.error_messages) > 0 @property def found_warns(self) -> bool: return len(self.warn_messages) > 0
[docs] def lint( self, name: str, lint_func: Callable[[LintTargetType, "LintContext"], None], lint_target: LintTargetType, module_name: Optional[str] = None, ): if name.startswith("lint_"): name = name[len("lint_") :] if name in self.skip_types: return if module_name and module_name in self.skip_types: return if self.level < LintLevel.SILENT: # this is a relict from the past where the lint context # was reset when called with a new lint_func, as workaround # we save the message list, apply the lint_func (which then # adds to the message_list) and restore the message list # at the end (+ append the new messages) tmp_message_list = list(self.message_list) self.message_list = [] # call linter lint_func(lint_target, self) if self.level < LintLevel.SILENT: for message in self.error_messages: print(f"{message}") if self.level <= LintLevel.WARN: for message in self.warn_messages: print(f"{message}") if self.level <= LintLevel.INFO: for message in self.info_messages: print(f"{message}") if self.level <= LintLevel.VALID: for message in self.valid_messages: print(f"{message}") self.message_list = tmp_message_list + self.message_list
@property def valid_messages(self) -> List[LintMessage]: return [x for x in self.message_list if x.level == "check"] @property def info_messages(self) -> List[LintMessage]: return [x for x in self.message_list if x.level == "info"] @property def warn_messages(self) -> List[LintMessage]: return [x for x in self.message_list if x.level == "warning"] @property def error_messages(self) -> List[LintMessage]: return [x for x in self.message_list if x.level == "error"] def __handle_message(self, level: str, message: str, linter: Optional[str] = None, *args, **kwargs) -> None: if args: message = message % args self.message_list.append(self.lint_message_class(level=level, message=message, linter=linter, **kwargs))
[docs] def valid(self, message: str, linter: Optional[str] = None, *args, **kwargs) -> None: self.__handle_message("check", message, linter, *args, **kwargs)
[docs] def info(self, message: str, linter: Optional[str] = None, *args, **kwargs) -> None: self.__handle_message("info", message, linter, *args, **kwargs)
[docs] def error(self, message: str, linter: Optional[str] = None, *args, **kwargs) -> None: self.__handle_message("error", message, linter, *args, **kwargs)
[docs] def warn(self, message: str, linter: Optional[str] = None, *args, **kwargs) -> None: self.__handle_message("warning", message, linter, *args, **kwargs)
[docs] def failed(self, fail_level: Union[LintLevel, str]) -> bool: if isinstance(fail_level, str): fail_level = LintLevel[fail_level.upper()] found_warns = self.found_warns found_errors = self.found_errors if fail_level == LintLevel.WARN: lint_fail = found_warns or found_errors elif fail_level >= LintLevel.ERROR: lint_fail = found_errors return lint_fail
[docs]def lint_tool_source( tool_source, level=LintLevel.ALL, fail_level=LintLevel.WARN, extra_modules=None, skip_types=None, name=None ) -> bool: """ apply all (applicable) linters from the linters submodule and the ones in extramodules immediately print linter messages (wrt level) and return if linting failed (wrt fail_level) """ extra_modules = extra_modules or [] skip_types = skip_types or [] lint_context = LintContext(level=level, skip_types=skip_types, object_name=name) lint_tool_source_with(lint_context, tool_source, extra_modules) return not lint_context.failed(fail_level)
[docs]def get_lint_context_for_tool_source(tool_source, extra_modules=None, skip_types=None, name=None) -> LintContext: """ this is the silent variant of lint_tool_source it returns the LintContext from which all linter messages and the status can be obtained """ extra_modules = extra_modules or [] skip_types = skip_types or [] lint_context = LintContext(level=LintLevel.SILENT, skip_types=skip_types, object_name=name) lint_tool_source_with(lint_context, tool_source, extra_modules) return lint_context
[docs]def lint_xml( tool_xml, level=LintLevel.ALL, fail_level=LintLevel.WARN, lint_message_class=LintMessage, extra_modules=None, skip_types=None, name=None, ) -> bool: """ lint an xml tool """ extra_modules = extra_modules or [] skip_types = skip_types or [] lint_context = LintContext( level=level, lint_message_class=lint_message_class, skip_types=skip_types, object_name=name ) lint_xml_with(lint_context, tool_xml, extra_modules) return not lint_context.failed(fail_level)
[docs]def lint_tool_source_with(lint_context, tool_source, extra_modules=None) -> LintContext: extra_modules = extra_modules or [] linter_modules = submodules.import_submodules(galaxy.tool_util.linters) linter_modules.extend(extra_modules) return lint_tool_source_with_modules(lint_context, tool_source, linter_modules)
[docs]def lint_tool_source_with_modules(lint_context: LintContext, tool_source, linter_modules) -> LintContext: tool_xml = getattr(tool_source, "xml_tree", None) tool_type = tool_source.parse_tool_type() or "default" for module in linter_modules: module_name = module.__name__.split(".")[-1] lint_tool_types = getattr(module, "lint_tool_types", ["default", "manage_data"]) if not ("*" in lint_tool_types or tool_type in lint_tool_types): continue for name, value in inspect.getmembers(module): if callable(value) and name.startswith("lint_"): # Look at the first argument to the linter to decide # if we should lint the XML description or the abstract # tool parser object. first_arg = inspect.getfullargspec(value).args[0] if first_arg == "tool_xml": if tool_xml is None: # XML linter and non-XML tool, skip for now continue else: lint_context.lint(name, value, tool_xml) else: lint_context.lint(name, value, tool_source) elif inspect.isclass(value) and issubclass(value, Linter) and not inspect.isabstract(value): lint_context.lint(name, value.lint, tool_source, module_name=module_name) return lint_context
[docs]def lint_xml_with(lint_context, tool_xml, extra_modules=None) -> LintContext: extra_modules = extra_modules or [] tool_source = get_tool_source(xml_tree=tool_xml) return lint_tool_source_with(lint_context, tool_source, extra_modules=extra_modules)