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