blob: bef26f73bd0be7661399b3ce09c012c97df17e0a [file] [log] [blame]
# -*- coding: utf-8 -*-
# Copyright 2017 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# This module is not automatically loaded by the `cros` helper. The filename
# would need a "cros_" prefix to make that happen. It lives here so that it
# is alongside the cros_lint.py file.
#
# For msg namespaces, the 9xxx should generally be reserved for our own use.
"""A lint module loaded by pylint for Autotest linting.
This module patches pylint library functions to suit autotest.
This is loaded by pylint directly via the autotest pylintrc file:
load-plugins=chromite.cli.cros.lint_autotest
"""
from __future__ import print_function
import re
from pylint.checkers import base
from pylint.checkers import BaseChecker
from pylint.checkers import imports
from pylint.checkers import variables
from pylint.interfaces import IAstroidChecker
# patch up pylint import checker to handle our importing magic
ROOT_MODULE = 'autotest_lib.'
# A list of modules for pylint to ignore, specifically, these modules
# are imported for their side-effects and are not meant to be used.
_IGNORE_MODULES = (
'common',
'frontend_test_utils',
'setup_django_environment',
'setup_django_lite_environment',
'setup_django_readonly_environment',
'setup_test_environment',
)
def patch_modname(modname):
"""Patches modname so we can make sense of autotest_lib modules.
Args:
modname: name of a module, contains '.'
Returns:
The modname string without the 'autotest_lib.' prefix. For example,
patch_modname('autotest_lib.foo.bar') == 'foo.bar'
patch_modname('foo.bar') == 'foo.bar'
"""
if modname.startswith(ROOT_MODULE) or modname.startswith(ROOT_MODULE[:-1]):
modname = modname[len(ROOT_MODULE):]
return modname
def patch_consumed_list(to_consume=None, consumed=None):
"""Patches the consumed modules list to ignore modules with side effects.
Autotest relies on importing certain modules solely for their side
effects. Pylint doesn't understand this and flags them as unused, since
they're not referenced anywhere in the code. To overcome this we need
to transplant said modules into the dictionary of modules pylint has
already seen, before pylint checks it.
Args:
to_consume: a dictionary of names pylint needs to see referenced.
consumed: a dictionary of names that pylint has seen referenced.
"""
if to_consume is None or consumed is None:
return
for module in _IGNORE_MODULES:
if module in to_consume:
consumed[module] = to_consume[module]
del to_consume[module]
# This decorator will be used for monkey patching the built-in pylint classes.
def patch_cls(cls):
"""Sets a method of `cls`."""
def patcher(method):
setattr(cls, method.__name__, method)
return patcher
def CustomizeImportsChecker():
"""Modifies stock imports checker to suit autotest."""
cls = imports.ImportsChecker
old_visit_importfrom = cls.visit_importfrom
@patch_cls(cls)
def visit_importfrom(self, node): # pylint: disable=unused-variable
node.names = patch_modname(node.names)
return old_visit_importfrom(self, node)
def CustomizeVariablesChecker():
"""Modifies stock variables checker to suit autotest."""
cls = variables.VariablesChecker
old_visit_module = cls.visit_module
@patch_cls(cls)
def visit_module(self, node): # pylint: disable=unused-variable
"""Unflag 'import common'.
_to_consume eg: [({to reference}, {referenced}, 'scope type')]
Enteries are appended to this list as we drill deeper in scope.
If we ever come across a module to ignore, we immediately move it
to the consumed list.
Args:
node: node of the ast we're currently checking.
"""
old_visit_module(self, node)
# pylint: disable=protected-access
scoped_names = self._to_consume.pop()
patch_consumed_list(scoped_names[0], scoped_names[1])
self._to_consume.append(scoped_names)
old_visit_importfrom = cls.visit_importfrom
@patch_cls(cls)
def visit_importfrom(self, node): # pylint: disable=unused-variable
"""Patches modnames so pylints understands autotest_lib."""
node.modname = patch_modname(node.modname)
return old_visit_importfrom(self, node)
def _ShouldSkipArg(arg):
"""Checks if arg name can be excluded from @param list.
Returns:
True if the argument given by arg is whitelisted, and does
not require a "@param" docstring.
"""
return arg in ('self', 'cls', 'args', 'kwargs', 'dargs')
def ShouldSkipDocstring(node):
"""Returns whether docstring checks should run on this function node.
Args:
node: The node under examination.
"""
# Even plain functions will have a parent, which is the
# module they're in, and a frame, which is the context
# of said module; They need not however, always have
# ancestors.
return (node.name in ('run_once', 'initialize', 'cleanup') and
hasattr(node.parent.frame(), 'ancestors') and
any(ancestor.name == 'base_test' for ancestor in
node.parent.frame().ancestors()))
def CustomizeDocStringChecker():
"""Modifies stock docstring checker to suit Autotest doxygen style."""
cls = base.DocStringChecker
@patch_cls(cls)
def visit_module(_self, _node): # pylint: disable=unused-variable
"""Don't visit imported modules when checking for docstrings.
Args:
node: the node we're visiting.
"""
old_visit_functiondef = cls.visit_functiondef
@patch_cls(cls)
def visit_functiondef(self, node): # pylint: disable=unused-variable
"""Don't request docstrings for commonly overridden autotest functions.
Args:
node: node of the ast we're currently checking.
"""
if ShouldSkipDocstring(node):
return
old_visit_functiondef(self, node)
class ParamChecker(BaseChecker):
"""Checks that each argument has a @param entry in the docstring."""
__implements__ = IAstroidChecker
# The numbering for this message matches that of the doc string checker class
# in chromite.cli.cros.lint
class _MessageCP010(object):
"""Message for missing @param statements."""
pass
name = 'doc_string_param_checker'
priority = -1
MSG_ARGS = 'offset:%(offset)i: {%(line)s}'
msgs = {
'C9010': ('Docstring for %(func)s needs "@param %(arg)s:"',
('docstring-missing-args'), _MessageCP010),
}
def visit_function(self, node):
"""Verify the function's docstrings."""
if node.doc and not ShouldSkipDocstring(node):
self._check_all_args_in_doc(node)
ARG_DOCSTRING_RGX = re.compile(r'@param ([^:]+)')
def _check_all_args_in_doc(self, node):
"""Teaches pylint to look for @param with each argument.
Args:
node_type: type of the node we're currently checking.
node: node of the ast we're currently checking.
"""
present_args = set(arg for arg in node.argnames()
if not _ShouldSkipArg(arg))
documented_args = set(re.findall(self.ARG_DOCSTRING_RGX, node.doc))
for undocumented in present_args - documented_args:
self.add_message('C9010', node=node,
line=node.fromlineno,
args={'arg': undocumented,
'func': node.name},)
def register(linter):
"""Pylint will call this func when we use the 'load-plugins' invocation.
Args:
linter: The pylint linter instance for this run.
"""
# Walk all the classes in this module and register ours.
linter.register_checker(ParamChecker(linter))
CustomizeDocStringChecker()
CustomizeImportsChecker()
CustomizeVariablesChecker()