• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/python -u
2"""
3Wrapper to patch pylint library functions to suit autotest.
4
5This script is invoked as part of the presubmit checks for autotest python
6files. It runs pylint on a list of files that it obtains either through
7the command line or from an environment variable set in pre-upload.py.
8
9Example:
10run_pylint.py filename.py
11"""
12
13import fnmatch
14import logging
15import os
16import re
17import sys
18
19import common
20from autotest_lib.client.common_lib import autotemp, revision_control
21
22# Do a basic check to see if pylint is even installed.
23try:
24    import pylint
25    from pylint.__pkginfo__ import version as pylint_version
26except ImportError:
27    print ("Unable to import pylint, it may need to be installed."
28           " Run 'sudo aptitude install pylint' if you haven't already.")
29    sys.exit(1)
30
31major, minor, release = pylint_version.split('.')
32pylint_version = float("%s.%s" % (major, minor))
33
34# some files make pylint blow up, so make sure we ignore them
35BLACKLIST = ['/site-packages/*', '/contrib/*', '/frontend/afe/management.py']
36
37import pylint.lint
38from pylint.checkers import base, imports, variables
39
40# need to put autotest root dir on sys.path so pylint will be happy
41autotest_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
42sys.path.insert(0, autotest_root)
43
44# patch up pylint import checker to handle our importing magic
45ROOT_MODULE = 'autotest_lib.'
46
47# A list of modules for pylint to ignore, specifically, these modules
48# are imported for their side-effects and are not meant to be used.
49_IGNORE_MODULES=['common', 'frontend_test_utils',
50                 'setup_django_environment',
51                 'setup_django_lite_environment',
52                 'setup_django_readonly_environment', 'setup_test_environment',]
53
54
55class pylint_error(Exception):
56    """
57    Error raised when pylint complains about a file.
58    """
59
60
61class run_pylint_error(pylint_error):
62    """
63    Error raised when an assumption made in this file is violated.
64    """
65
66
67def patch_modname(modname):
68    """
69    Patches modname so we can make sense of autotest_lib modules.
70
71    @param modname: name of a module, contains '.'
72    @return modified modname string.
73    """
74    if modname.startswith(ROOT_MODULE) or modname.startswith(ROOT_MODULE[:-1]):
75        modname = modname[len(ROOT_MODULE):]
76    return modname
77
78
79def patch_consumed_list(to_consume=None, consumed=None):
80    """
81    Patches the consumed modules list to ignore modules with side effects.
82
83    Autotest relies on importing certain modules solely for their side
84    effects. Pylint doesn't understand this and flags them as unused, since
85    they're not referenced anywhere in the code. To overcome this we need
86    to transplant said modules into the dictionary of modules pylint has
87    already seen, before pylint checks it.
88
89    @param to_consume: a dictionary of names pylint needs to see referenced.
90    @param consumed: a dictionary of names that pylint has seen referenced.
91    """
92    ignore_modules = []
93    if (to_consume is not None and consumed is not None):
94        ignore_modules = [module_name for module_name in _IGNORE_MODULES
95                          if module_name in to_consume]
96
97    for module_name in ignore_modules:
98        consumed[module_name] = to_consume[module_name]
99        del to_consume[module_name]
100
101
102class CustomImportsChecker(imports.ImportsChecker):
103    """Modifies stock imports checker to suit autotest."""
104    def visit_importfrom(self, node):
105        """Patches modnames so pylints understands autotest_lib."""
106        node.modname = patch_modname(node.modname)
107        return super(CustomImportsChecker, self).visit_importfrom(node)
108
109
110class CustomVariablesChecker(variables.VariablesChecker):
111    """Modifies stock variables checker to suit autotest."""
112
113    def visit_module(self, node):
114        """
115        Unflag 'import common'.
116
117        _to_consume eg: [({to reference}, {referenced}, 'scope type')]
118        Enteries are appended to this list as we drill deeper in scope.
119        If we ever come across a module to ignore,  we immediately move it
120        to the consumed list.
121
122        @param node: node of the ast we're currently checking.
123        """
124        super(CustomVariablesChecker, self).visit_module(node)
125        scoped_names = self._to_consume.pop()
126        patch_consumed_list(scoped_names[0],scoped_names[1])
127        self._to_consume.append(scoped_names)
128
129    def visit_importfrom(self, node):
130        """Patches modnames so pylints understands autotest_lib."""
131        node.modname = patch_modname(node.modname)
132        return super(CustomVariablesChecker, self).visit_importfrom(node)
133
134
135class CustomDocStringChecker(base.DocStringChecker):
136    """Modifies stock docstring checker to suit Autotest doxygen style."""
137
138    def visit_module(self, node):
139        """
140        Don't visit imported modules when checking for docstrings.
141
142        @param node: the node we're visiting.
143        """
144        pass
145
146
147    def visit_functiondef(self, node):
148        """
149        Don't request docstrings for commonly overridden autotest functions.
150
151        @param node: node of the ast we're currently checking.
152        """
153
154        # Even plain functions will have a parent, which is the
155        # module they're in, and a frame, which is the context
156        # of said module; They need not however, always have
157        # ancestors.
158        if (node.name in ('run_once', 'initialize', 'cleanup') and
159            hasattr(node.parent.frame(), 'ancestors') and
160            any(ancestor.name == 'base_test' for ancestor in
161                node.parent.frame().ancestors())):
162            return
163
164        if _is_test_case_method(node):
165            return
166
167        super(CustomDocStringChecker, self).visit_functiondef(node)
168
169
170    @staticmethod
171    def _should_skip_arg(arg):
172        """
173        @return: True if the argument given by arg is whitelisted, and does
174                 not require a "@param" docstring.
175        """
176        return arg in ('self', 'cls', 'args', 'kwargs', 'dargs')
177
178base.DocStringChecker = CustomDocStringChecker
179imports.ImportsChecker = CustomImportsChecker
180variables.VariablesChecker = CustomVariablesChecker
181
182
183def batch_check_files(file_paths, base_opts):
184    """
185    Run pylint on a list of files so we get consolidated errors.
186
187    @param file_paths: a list of file paths.
188    @param base_opts: a list of pylint config options.
189
190    @returns pylint return code
191
192    @raises: pylint_error if pylint finds problems with a file
193             in this commit.
194    """
195    if not file_paths:
196        return 0
197
198    pylint_runner = pylint.lint.Run(list(base_opts) + list(file_paths),
199                                    exit=False)
200    return pylint_runner.linter.msg_status
201
202
203def should_check_file(file_path):
204    """
205    Don't check blacklisted or non .py files.
206
207    @param file_path: abs path of file to check.
208    @return: True if this file is a non-blacklisted python file.
209    """
210    file_path = os.path.abspath(file_path)
211    if file_path.endswith('.py'):
212        return all(not fnmatch.fnmatch(file_path, '*' + pattern)
213                   for pattern in BLACKLIST)
214    return False
215
216
217def check_file(file_path, base_opts):
218    """
219    Invokes pylint on files after confirming that they're not black listed.
220
221    @param base_opts: pylint base options.
222    @param file_path: path to the file we need to run pylint on.
223
224    @returns pylint return code
225    """
226    if not isinstance(file_path, basestring):
227        raise TypeError('expected a string as filepath, got %s'%
228            type(file_path))
229
230    if should_check_file(file_path):
231        pylint_runner = pylint.lint.Run(base_opts + [file_path], exit=False)
232
233        return pylint_runner.linter.msg_status
234
235    return 0
236
237
238def visit(arg, dirname, filenames):
239    """
240    Visit function invoked in check_dir.
241
242    @param arg: arg from os.walk.path
243    @param dirname: dir from os.walk.path
244    @param filenames: files in dir from os.walk.path
245    """
246    for filename in filenames:
247        arg.append(os.path.join(dirname, filename))
248
249
250def check_dir(dir_path, base_opts):
251    """
252    Calls visit on files in dir_path.
253
254    @param base_opts: pylint base options.
255    @param dir_path: path to directory.
256
257    @returns pylint return code
258    """
259    files = []
260
261    os.path.walk(dir_path, visit, files)
262
263    return batch_check_files(files, base_opts)
264
265
266def extend_baseopts(base_opts, new_opt):
267    """
268    Replaces an argument in base_opts with a cmd line argument.
269
270    @param base_opts: original pylint_base_opts.
271    @param new_opt: new cmd line option.
272    """
273    for args in base_opts:
274        if new_opt in args:
275            base_opts.remove(args)
276    base_opts.append(new_opt)
277
278
279def get_cmdline_options(args_list, pylint_base_opts, rcfile):
280    """
281    Parses args_list and extends pylint_base_opts.
282
283    Command line arguments might include options mixed with files.
284    Go through this list and filter out the options, if the options are
285    specified in the pylintrc file we cannot replace them and the file
286    needs to be edited. If the options are already a part of
287    pylint_base_opts we replace them, and if not we append to
288    pylint_base_opts.
289
290    @param args_list: list of files/pylint args passed in through argv.
291    @param pylint_base_opts: default pylint options.
292    @param rcfile: text from pylint_rc.
293    """
294    for args in args_list:
295        if args.startswith('--'):
296            opt_name = args[2:].split('=')[0]
297            extend_baseopts(pylint_base_opts, args)
298            args_list.remove(args)
299
300
301def git_show_to_temp_file(commit, original_file, new_temp_file):
302    """
303    'Git shows' the file in original_file to a tmp file with
304    the name new_temp_file. We need to preserve the filename
305    as it gets reflected in pylints error report.
306
307    @param commit: commit hash of the commit we're running repo upload on.
308    @param original_file: the path to the original file we'd like to run
309                          'git show' on.
310    @param new_temp_file: new_temp_file is the path to a temp file we write the
311                          output of 'git show' into.
312    """
313    git_repo = revision_control.GitRepo(common.autotest_dir, None, None,
314        common.autotest_dir)
315
316    with open(new_temp_file, 'w') as f:
317        output = git_repo.gitcmd('show --no-ext-diff %s:%s'
318                                 % (commit, original_file),
319                                 ignore_status=False).stdout
320        f.write(output)
321
322
323def check_committed_files(work_tree_files, commit, pylint_base_opts):
324    """
325    Get a list of files corresponding to the commit hash.
326
327    The contents of a file in the git work tree can differ from the contents
328    of a file in the commit we mean to upload. To work around this we run
329    pylint on a temp file into which we've 'git show'n the committed version
330    of each file.
331
332    @param work_tree_files: list of files in this commit specified by their
333                            absolute path.
334    @param commit: hash of the commit this upload applies to.
335    @param pylint_base_opts: a list of pylint config options.
336
337    @returns pylint return code
338    """
339    files_to_check = filter(should_check_file, work_tree_files)
340
341    # Map the absolute path of each file so it's relative to the autotest repo.
342    # All files that are a part of this commit should have an abs path within
343    # the autotest repo, so this regex should never fail.
344    work_tree_files = [re.search(r'%s/(.*)' % common.autotest_dir, f).group(1)
345                       for f in files_to_check]
346
347    tempdir = None
348    try:
349        tempdir = autotemp.tempdir()
350        temp_files = [os.path.join(tempdir.name, file_path.split('/')[-1:][0])
351                      for file_path in work_tree_files]
352
353        for file_tuple in zip(work_tree_files, temp_files):
354            git_show_to_temp_file(commit, *file_tuple)
355        # Only check if we successfully git showed all files in the commit.
356        return batch_check_files(temp_files, pylint_base_opts)
357    finally:
358        if tempdir:
359            tempdir.clean()
360
361
362def _is_test_case_method(node):
363    """Determine if the given function node is a method of a TestCase.
364
365    We simply check for 'TestCase' being one of the parent classes in the mro of
366    the containing class.
367
368    @params node: A function node.
369    """
370    if not hasattr(node.parent.frame(), 'ancestors'):
371        return False
372
373    parent_class_names = {x.name for x in node.parent.frame().ancestors()}
374    return 'TestCase' in parent_class_names
375
376
377def main():
378    """Main function checks each file in a commit for pylint violations."""
379
380    # For now all error/warning/refactor/convention exceptions except those in
381    # the enable string are disabled.
382    # W0611: All imported modules (except common) need to be used.
383    # W1201: Logging methods should take the form
384    #   logging.<loggingmethod>(format_string, format_args...); and not
385    #   logging.<loggingmethod>(format_string % (format_args...))
386    # C0111: Docstring needed. Also checks @param for each arg.
387    # C0112: Non-empty Docstring needed.
388    # Ideally we would like to enable as much as we can, but if we did so at
389    # this stage anyone who makes a tiny change to a file will be tasked with
390    # cleaning all the lint in it. See chromium-os:37364.
391
392    # Note:
393    # 1. There are three major sources of E1101/E1103/E1120 false positives:
394    #    * common_lib.enum.Enum objects
395    #    * DB model objects (scheduler models are the worst, but Django models
396    #      also generate some errors)
397    # 2. Docstrings are optional on private methods, and any methods that begin
398    #    with either 'set_' or 'get_'.
399    pylint_rc = os.path.join(os.path.dirname(os.path.abspath(__file__)),
400                             'pylintrc')
401
402    no_docstring_rgx = r'((_.*)|(set_.*)|(get_.*))'
403    if pylint_version >= 0.21:
404        pylint_base_opts = ['--rcfile=%s' % pylint_rc,
405                            '--reports=no',
406                            '--disable=W,R,E,C,F',
407                            '--enable=W0611,W1201,C0111,C0112,E0602,W0601',
408                            '--no-docstring-rgx=%s' % no_docstring_rgx,]
409    else:
410        all_failures = 'error,warning,refactor,convention'
411        pylint_base_opts = ['--disable-msg-cat=%s' % all_failures,
412                            '--reports=no',
413                            '--include-ids=y',
414                            '--ignore-docstrings=n',
415                            '--no-docstring-rgx=%s' % no_docstring_rgx,]
416
417    # run_pylint can be invoked directly with command line arguments,
418    # or through a presubmit hook which uses the arguments in pylintrc. In the
419    # latter case no command line arguments are passed. If it is invoked
420    # directly without any arguments, it should check all files in the cwd.
421    args_list = sys.argv[1:]
422    if args_list:
423        get_cmdline_options(args_list,
424                            pylint_base_opts,
425                            open(pylint_rc).read())
426        return batch_check_files(args_list, pylint_base_opts)
427    elif os.environ.get('PRESUBMIT_FILES') is not None:
428        return check_committed_files(
429                              os.environ.get('PRESUBMIT_FILES').split('\n'),
430                              os.environ.get('PRESUBMIT_COMMIT'),
431                              pylint_base_opts)
432    else:
433        return check_dir('.', pylint_base_opts)
434
435
436if __name__ == '__main__':
437    try:
438        ret = main()
439
440        sys.exit(ret)
441    except pylint_error as e:
442        logging.error(e)
443        sys.exit(1)
444