• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/python2 -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
31pylint_version_parsed = tuple(map(int, pylint_version.split('.')))
32
33# some files make pylint blow up, so make sure we ignore them
34BLACKLIST = ['/site-packages/*', '/contrib/*', '/frontend/afe/management.py']
35
36import astroid
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        # The type of the object has changed in pylint 1.8.2
127        if pylint_version_parsed >= (1, 8, 2):
128            patch_consumed_list(scoped_names.to_consume,scoped_names.consumed)
129        else:
130            patch_consumed_list(scoped_names[0],scoped_names[1])
131        self._to_consume.append(scoped_names)
132
133    def visit_importfrom(self, node):
134        """Patches modnames so pylints understands autotest_lib."""
135        node.modname = patch_modname(node.modname)
136        return super(CustomVariablesChecker, self).visit_importfrom(node)
137
138    def visit_expr(self, node):
139        """
140        Flag exceptions instantiated but not used.
141
142        https://crbug.com/1005893
143        """
144        if not isinstance(node.value, astroid.Call):
145            return
146        func = node.value.func
147        try:
148            cls = next(func.infer())
149        except astroid.InferenceError:
150            return
151        if not isinstance(cls, astroid.ClassDef):
152            return
153        if any(x for x in cls.ancestors() if x.name == 'BaseException'):
154            self.add_message('W0104', node=node, line=node.fromlineno)
155
156
157class CustomDocStringChecker(base.DocStringChecker):
158    """Modifies stock docstring checker to suit Autotest doxygen style."""
159
160    def visit_module(self, node):
161        """
162        Don't visit imported modules when checking for docstrings.
163
164        @param node: the node we're visiting.
165        """
166        pass
167
168
169    def visit_functiondef(self, node):
170        """
171        Don't request docstrings for commonly overridden autotest functions.
172
173        @param node: node of the ast we're currently checking.
174        """
175
176        # Even plain functions will have a parent, which is the
177        # module they're in, and a frame, which is the context
178        # of said module; They need not however, always have
179        # ancestors.
180        if (node.name in ('run_once', 'initialize', 'cleanup') and
181            hasattr(node.parent.frame(), 'ancestors') and
182            any(ancestor.name == 'base_test' for ancestor in
183                node.parent.frame().ancestors())):
184            return
185
186        if _is_test_case_method(node):
187            return
188
189        super(CustomDocStringChecker, self).visit_functiondef(node)
190
191
192    @staticmethod
193    def _should_skip_arg(arg):
194        """
195        @return: True if the argument given by arg is whitelisted, and does
196                 not require a "@param" docstring.
197        """
198        return arg in ('self', 'cls', 'args', 'kwargs', 'dargs')
199
200base.DocStringChecker = CustomDocStringChecker
201imports.ImportsChecker = CustomImportsChecker
202variables.VariablesChecker = CustomVariablesChecker
203
204
205def batch_check_files(file_paths, base_opts):
206    """
207    Run pylint on a list of files so we get consolidated errors.
208
209    @param file_paths: a list of file paths.
210    @param base_opts: a list of pylint config options.
211
212    @returns pylint return code
213
214    @raises: pylint_error if pylint finds problems with a file
215             in this commit.
216    """
217    if not file_paths:
218        return 0
219
220    pylint_runner = pylint.lint.Run(list(base_opts) + list(file_paths),
221                                    exit=False)
222    return pylint_runner.linter.msg_status
223
224
225def should_check_file(file_path):
226    """
227    Don't check blacklisted or non .py files.
228
229    @param file_path: abs path of file to check.
230    @return: True if this file is a non-blacklisted python file.
231    """
232    file_path = os.path.abspath(file_path)
233    if file_path.endswith('.py'):
234        return all(not fnmatch.fnmatch(file_path, '*' + pattern)
235                   for pattern in BLACKLIST)
236    return False
237
238
239def check_file(file_path, base_opts):
240    """
241    Invokes pylint on files after confirming that they're not black listed.
242
243    @param base_opts: pylint base options.
244    @param file_path: path to the file we need to run pylint on.
245
246    @returns pylint return code
247    """
248    if not isinstance(file_path, basestring):
249        raise TypeError('expected a string as filepath, got %s'%
250            type(file_path))
251
252    if should_check_file(file_path):
253        pylint_runner = pylint.lint.Run(base_opts + [file_path], exit=False)
254
255        return pylint_runner.linter.msg_status
256
257    return 0
258
259
260def visit(arg, dirname, filenames):
261    """
262    Visit function invoked in check_dir.
263
264    @param arg: arg from os.walk.path
265    @param dirname: dir from os.walk.path
266    @param filenames: files in dir from os.walk.path
267    """
268    for filename in filenames:
269        arg.append(os.path.join(dirname, filename))
270
271
272def check_dir(dir_path, base_opts):
273    """
274    Calls visit on files in dir_path.
275
276    @param base_opts: pylint base options.
277    @param dir_path: path to directory.
278
279    @returns pylint return code
280    """
281    files = []
282
283    os.path.walk(dir_path, visit, files)
284
285    return batch_check_files(files, base_opts)
286
287
288def extend_baseopts(base_opts, new_opt):
289    """
290    Replaces an argument in base_opts with a cmd line argument.
291
292    @param base_opts: original pylint_base_opts.
293    @param new_opt: new cmd line option.
294    """
295    for args in base_opts:
296        if new_opt in args:
297            base_opts.remove(args)
298    base_opts.append(new_opt)
299
300
301def get_cmdline_options(args_list, pylint_base_opts, rcfile):
302    """
303    Parses args_list and extends pylint_base_opts.
304
305    Command line arguments might include options mixed with files.
306    Go through this list and filter out the options, if the options are
307    specified in the pylintrc file we cannot replace them and the file
308    needs to be edited. If the options are already a part of
309    pylint_base_opts we replace them, and if not we append to
310    pylint_base_opts.
311
312    @param args_list: list of files/pylint args passed in through argv.
313    @param pylint_base_opts: default pylint options.
314    @param rcfile: text from pylint_rc.
315    """
316    for args in args_list:
317        if args.startswith('--'):
318            opt_name = args[2:].split('=')[0]
319            extend_baseopts(pylint_base_opts, args)
320            args_list.remove(args)
321
322
323def git_show_to_temp_file(commit, original_file, new_temp_file):
324    """
325    'Git shows' the file in original_file to a tmp file with
326    the name new_temp_file. We need to preserve the filename
327    as it gets reflected in pylints error report.
328
329    @param commit: commit hash of the commit we're running repo upload on.
330    @param original_file: the path to the original file we'd like to run
331                          'git show' on.
332    @param new_temp_file: new_temp_file is the path to a temp file we write the
333                          output of 'git show' into.
334    """
335    git_repo = revision_control.GitRepo(common.autotest_dir, None, None,
336        common.autotest_dir)
337
338    with open(new_temp_file, 'w') as f:
339        output = git_repo.gitcmd('show --no-ext-diff %s:%s'
340                                 % (commit, original_file),
341                                 ignore_status=False).stdout
342        f.write(output)
343
344
345def check_committed_files(work_tree_files, commit, pylint_base_opts):
346    """
347    Get a list of files corresponding to the commit hash.
348
349    The contents of a file in the git work tree can differ from the contents
350    of a file in the commit we mean to upload. To work around this we run
351    pylint on a temp file into which we've 'git show'n the committed version
352    of each file.
353
354    @param work_tree_files: list of files in this commit specified by their
355                            absolute path.
356    @param commit: hash of the commit this upload applies to.
357    @param pylint_base_opts: a list of pylint config options.
358
359    @returns pylint return code
360    """
361    files_to_check = filter(should_check_file, work_tree_files)
362
363    # Map the absolute path of each file so it's relative to the autotest repo.
364    # All files that are a part of this commit should have an abs path within
365    # the autotest repo, so this regex should never fail.
366    work_tree_files = [re.search(r'%s/(.*)' % common.autotest_dir, f).group(1)
367                       for f in files_to_check]
368
369    tempdir = None
370    try:
371        tempdir = autotemp.tempdir()
372        temp_files = [os.path.join(tempdir.name, file_path.split('/')[-1:][0])
373                      for file_path in work_tree_files]
374
375        for file_tuple in zip(work_tree_files, temp_files):
376            git_show_to_temp_file(commit, *file_tuple)
377        # Only check if we successfully git showed all files in the commit.
378        return batch_check_files(temp_files, pylint_base_opts)
379    finally:
380        if tempdir:
381            tempdir.clean()
382
383
384def _is_test_case_method(node):
385    """Determine if the given function node is a method of a TestCase.
386
387    We simply check for 'TestCase' being one of the parent classes in the mro of
388    the containing class.
389
390    @params node: A function node.
391    """
392    if not hasattr(node.parent.frame(), 'ancestors'):
393        return False
394
395    parent_class_names = {x.name for x in node.parent.frame().ancestors()}
396    return 'TestCase' in parent_class_names
397
398
399def main():
400    """Main function checks each file in a commit for pylint violations."""
401
402    # For now all error/warning/refactor/convention exceptions except those in
403    # the enable string are disabled.
404    # W0611: All imported modules (except common) need to be used.
405    # W1201: Logging methods should take the form
406    #   logging.<loggingmethod>(format_string, format_args...); and not
407    #   logging.<loggingmethod>(format_string % (format_args...))
408    # C0111: Docstring needed. Also checks @param for each arg.
409    # C0112: Non-empty Docstring needed.
410    # Ideally we would like to enable as much as we can, but if we did so at
411    # this stage anyone who makes a tiny change to a file will be tasked with
412    # cleaning all the lint in it. See chromium-os:37364.
413
414    # Note:
415    # 1. There are three major sources of E1101/E1103/E1120 false positives:
416    #    * common_lib.enum.Enum objects
417    #    * DB model objects (scheduler models are the worst, but Django models
418    #      also generate some errors)
419    # 2. Docstrings are optional on private methods, and any methods that begin
420    #    with either 'set_' or 'get_'.
421    pylint_rc = os.path.join(os.path.dirname(os.path.abspath(__file__)),
422                             'pylintrc')
423
424    no_docstring_rgx = r'((_.*)|(set_.*)|(get_.*))'
425    if pylint_version_parsed >= (0, 21):
426        pylint_base_opts = ['--rcfile=%s' % pylint_rc,
427                            '--reports=no',
428                            '--disable=W,R,E,C,F',
429                            '--enable=W0104,W0611,W1201,C0111,C0112,E0602,'
430                            'W0601,E0633',
431                            '--no-docstring-rgx=%s' % no_docstring_rgx,]
432    else:
433        all_failures = 'error,warning,refactor,convention'
434        pylint_base_opts = ['--disable-msg-cat=%s' % all_failures,
435                            '--reports=no',
436                            '--include-ids=y',
437                            '--ignore-docstrings=n',
438                            '--no-docstring-rgx=%s' % no_docstring_rgx,]
439
440    # run_pylint can be invoked directly with command line arguments,
441    # or through a presubmit hook which uses the arguments in pylintrc. In the
442    # latter case no command line arguments are passed. If it is invoked
443    # directly without any arguments, it should check all files in the cwd.
444    args_list = sys.argv[1:]
445    if args_list:
446        get_cmdline_options(args_list,
447                            pylint_base_opts,
448                            open(pylint_rc).read())
449        return batch_check_files(args_list, pylint_base_opts)
450    elif os.environ.get('PRESUBMIT_FILES') is not None:
451        return check_committed_files(
452                              os.environ.get('PRESUBMIT_FILES').split('\n'),
453                              os.environ.get('PRESUBMIT_COMMIT'),
454                              pylint_base_opts)
455    else:
456        return check_dir('.', pylint_base_opts)
457
458
459if __name__ == '__main__':
460    try:
461        ret = main()
462
463        sys.exit(ret)
464    except pylint_error as e:
465        logging.error(e)
466        sys.exit(1)
467