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