xref: /aosp_15_r20/tools/repohooks/tools/pylint.py (revision d68f33bc6fb0cc2476107c2af0573a2f5a63dfc1)
1#!/usr/bin/env python3
2# Copyright 2016 The Android Open Source Project
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8#      http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""Wrapper to run pylint with the right settings."""
17
18import argparse
19import errno
20import os
21import shutil
22import sys
23import subprocess
24from typing import Dict, List, Optional, Set
25
26
27assert (sys.version_info.major, sys.version_info.minor) >= (3, 6), (
28    f'Python 3.6 or newer is required; found {sys.version}')
29
30
31DEFAULT_PYLINTRC_PATH = os.path.join(
32    os.path.dirname(os.path.realpath(__file__)), 'pylintrc')
33
34
35def is_pylint3(pylint):
36    """See whether |pylint| supports Python 3."""
37    # Make sure pylint is using Python 3.
38    result = subprocess.run([pylint, '--version'], stdout=subprocess.PIPE,
39                            check=True)
40    if b'Python 3' not in result.stdout:
41        print(f'{__file__}: unable to locate a Python 3 version of pylint; '
42              'Python 3 support cannot be guaranteed', file=sys.stderr)
43        return False
44
45    return True
46
47
48def find_pylint3():
49    """Figure out the name of the pylint tool for Python 3.
50
51    It keeps changing with Python 2->3 migrations.  Fun.
52    """
53    # Prefer pylint3 as that's what we want.
54    if shutil.which('pylint3'):
55        return 'pylint3'
56
57    # If there's no pylint, give up.
58    if not shutil.which('pylint'):
59        print(f'{__file__}: unable to locate pylint; please install:\n'
60              'sudo apt-get install pylint', file=sys.stderr)
61        sys.exit(1)
62
63    return 'pylint'
64
65
66def run_lint(pylint: str, unknown: Optional[List[str]],
67             files: Optional[List[str]], init_hook: str,
68             pylintrc: Optional[str] = None) -> bool:
69    """Run lint command.
70
71    Upon error the stdout from pylint will be dumped to stdout and
72    False will be returned.
73    """
74    cmd = [pylint]
75
76    if not files:
77        # No files to analyze for this pylintrc file.
78        return True
79
80    if pylintrc:
81        cmd += ['--rcfile', pylintrc]
82
83    files.sort()
84    cmd += unknown + files
85
86    if init_hook:
87        cmd += ['--init-hook', init_hook]
88
89    try:
90        result = subprocess.run(cmd, stdout=subprocess.PIPE, text=True,
91                                check=False)
92    except OSError as e:
93        if e.errno == errno.ENOENT:
94            print(f'{__file__}: unable to run `{cmd[0]}`: {e}',
95                  file=sys.stderr)
96            print(f'{__file__}: Try installing pylint: sudo apt-get install '
97                  f'{os.path.basename(cmd[0])}', file=sys.stderr)
98            return False
99
100        raise
101
102    if result.returncode:
103        print(f'{__file__}: Using pylintrc: {pylintrc}')
104        print(result.stdout)
105        return False
106
107    return True
108
109
110def find_parent_dirs_with_pylintrc(leafdir: str,
111                                   pylintrc_map: Dict[str, Set[str]]) -> None:
112    """Find all dirs containing a pylintrc between root dir and leafdir."""
113
114    # Find all pylintrc files, store the path. The path must end with '/'
115    # to make sure that string compare can be used to compare with full
116    # path to python files later.
117
118    rootdir = os.path.abspath(".") + os.sep
119    key = os.path.abspath(leafdir) + os.sep
120
121    if not key.startswith(rootdir):
122        sys.exit(f'{__file__}: The search directory {key} is outside the '
123                 f'repo dir {rootdir}')
124
125    while rootdir != key:
126        # This subdirectory has already been handled, skip it.
127        if key in pylintrc_map:
128            break
129
130        if os.path.exists(os.path.join(key, 'pylintrc')):
131            pylintrc_map.setdefault(key, set())
132            break
133
134        # Go up one directory.
135        key = os.path.abspath(os.path.join(key, os.pardir)) + os.sep
136
137
138def map_pyfiles_to_pylintrc(files: List[str]) -> Dict[str, Set[str]]:
139    """ Map all python files to a pylintrc file.
140
141    Generate dictionary with pylintrc-file dirnames (including trailing /)
142    as key containing sets with corresponding python files.
143    """
144
145    pylintrc_map = {}
146    # We assume pylint is running in the top directory of the project,
147    # so load the pylintrc file from there if it is available.
148    pylintrc = os.path.abspath('pylintrc')
149    if not os.path.exists(pylintrc):
150        pylintrc = DEFAULT_PYLINTRC_PATH
151        # If we pass a non-existent rcfile to pylint, it'll happily ignore
152        # it.
153        assert os.path.exists(pylintrc), f'Could not find {pylintrc}'
154    # Always add top directory, either there is a pylintrc or fallback to
155    # default.
156    key = os.path.abspath('.') + os.sep
157    pylintrc_map[key] = set()
158
159    search_dirs = {os.path.dirname(x) for x in files}
160    for search_dir in search_dirs:
161        find_parent_dirs_with_pylintrc(search_dir, pylintrc_map)
162
163    # List of directories where pylintrc files are stored, most
164    # specific path first.
165    rc_dir_names = sorted(pylintrc_map, reverse=True)
166    # Map all python files to a pylintrc file.
167    for f in files:
168        f_full = os.path.abspath(f)
169        for rc_dir in rc_dir_names:
170            # The pylintrc map keys always have trailing /.
171            if f_full.startswith(rc_dir):
172                pylintrc_map[rc_dir].add(f)
173                break
174        else:
175            sys.exit(f'{__file__}: Failed to map file {f} to a pylintrc file.')
176
177    return pylintrc_map
178
179
180def get_parser():
181    """Return a command line parser."""
182    parser = argparse.ArgumentParser(description=__doc__)
183    parser.add_argument('--init-hook', help='Init hook commands to run.')
184    parser.add_argument('--py3', action='store_true',
185                        help='Force Python 3 mode')
186    parser.add_argument('--executable-path',
187                        help='The path of the pylint executable.')
188    parser.add_argument('--no-rcfile', dest='use_default_conf',
189                        help='Specify to use the executable\'s default '
190                        'configuration.',
191                        action='store_true')
192    parser.add_argument('files', nargs='+')
193    return parser
194
195
196def main(argv):
197    """The main entry."""
198    parser = get_parser()
199    opts, unknown = parser.parse_known_args(argv)
200    ret = 0
201
202    pylint = opts.executable_path
203    if pylint is None:
204        if opts.py3:
205            pylint = find_pylint3()
206        else:
207            pylint = 'pylint'
208
209    # Make sure pylint is using Python 3.
210    if opts.py3:
211        is_pylint3(pylint)
212
213    if not opts.use_default_conf:
214        pylintrc_map = map_pyfiles_to_pylintrc(opts.files)
215        first = True
216        for rc_dir, files in sorted(pylintrc_map.items()):
217            pylintrc = os.path.join(rc_dir, 'pylintrc')
218            if first:
219                first = False
220                assert os.path.abspath(rc_dir) == os.path.abspath('.'), (
221                    f'{__file__}: pylintrc in top dir not first in list')
222                if not os.path.exists(pylintrc):
223                    pylintrc = DEFAULT_PYLINTRC_PATH
224            if not run_lint(pylint, unknown, sorted(files),
225                            opts.init_hook, pylintrc):
226                ret = 1
227    # Not using rc files, pylint default behaviour.
228    elif not run_lint(pylint, unknown, sorted(opts.files), opts.init_hook):
229        ret = 1
230
231    return ret
232
233
234if __name__ == '__main__':
235    sys.exit(main(sys.argv[1:]))
236