1 #!/usr/bin/python -u 2 """ 3 Wrapper to patch pylint library functions to suit autotest. 4 5 This script is invoked as part of the presubmit checks for autotest python 6 files. It runs pylint on a list of files that it obtains either through 7 the command line or from an environment variable set in pre-upload.py. 8 9 Example: 10 run_pylint.py filename.py 11 """ 12 13 import fnmatch 14 import logging 15 import os 16 import re 17 import sys 18 19 import common 20 from autotest_lib.client.common_lib import autotemp, revision_control 21 22 # Do a basic check to see if pylint is even installed. 23 try: 24 import pylint 25 from pylint.__pkginfo__ import version as pylint_version 26 except 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 31 major, minor, release = pylint_version.split('.') 32 pylint_version = float("%s.%s" % (major, minor)) 33 34 # some files make pylint blow up, so make sure we ignore them 35 BLACKLIST = ['/contrib/*', '/frontend/afe/management.py'] 36 37 # patch up the logilab module lookup tools to understand autotest_lib.* trash 38 import logilab.common.modutils 39 _ffm = logilab.common.modutils.file_from_modpath 40 def 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) 54 logilab.common.modutils.file_from_modpath = file_from_modpath 55 56 57 import pylint.lint 58 from pylint.checkers import base, imports, variables 59 60 # need to put autotest root dir on sys.path so pylint will be happy 61 autotest_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) 62 sys.path.insert(0, autotest_root) 63 64 # patch up pylint import checker to handle our importing magic 65 ROOT_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 75 class pylint_error(Exception): 76 """ 77 Error raised when pylint complains about a file. 78 """ 79 80 81 class run_pylint_error(pylint_error): 82 """ 83 Error raised when an assumption made in this file is violated. 84 """ 85 86 87 def 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 99 def 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 122 class 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 129 class 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 154 class 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 super(CustomDocStringChecker, self).visit_function(node) 184 185 186 @staticmethod 187 def _should_skip_arg(arg): 188 """ 189 @return: True if the argument given by arg is whitelisted, and does 190 not require a "@param" docstring. 191 """ 192 return arg in ('self', 'cls', 'args', 'kwargs', 'dargs') 193 194 195 def _check_docstring(self, node_type, node): 196 """ 197 Teaches pylint to look for @param with each argument in the 198 function/method signature. 199 200 @param node_type: type of the node we're currently checking. 201 @param node: node of the ast we're currently checking. 202 """ 203 super(CustomDocStringChecker, self)._check_docstring(node_type, node) 204 docstring = node.doc 205 if pylint_version >= 1.1: 206 key = 'missing-docstring' 207 else: 208 key = 'C0111' 209 210 if (docstring is not None and 211 (node_type is 'method' or 212 node_type is 'function')): 213 args = node.argnames() 214 old_msg = self.linter._messages[key].msg 215 for arg in args: 216 arg_docstring_rgx = '.*@param '+arg+'.*' 217 line = re.search(arg_docstring_rgx, node.doc) 218 if not line and not self._should_skip_arg(arg): 219 self.linter._messages[key].msg = ('Docstring needs ' 220 '"@param '+arg+':"') 221 self.add_message(key, node=node) 222 self.linter._messages[key].msg = old_msg 223 224 base.DocStringChecker = CustomDocStringChecker 225 imports.ImportsChecker = CustomImportsChecker 226 variables.VariablesChecker = CustomVariablesChecker 227 228 229 def batch_check_files(file_paths, base_opts): 230 """ 231 Run pylint on a list of files so we get consolidated errors. 232 233 @param file_paths: a list of file paths. 234 @param base_opts: a list of pylint config options. 235 236 @raises: pylint_error if pylint finds problems with a file 237 in this commit. 238 """ 239 if not file_paths: 240 return 241 242 pylint_runner = pylint.lint.Run(list(base_opts) + list(file_paths), 243 exit=False) 244 if pylint_runner.linter.msg_status: 245 raise pylint_error(pylint_runner.linter.msg_status) 246 247 248 def should_check_file(file_path): 249 """ 250 Don't check blacklisted or non .py files. 251 252 @param file_path: abs path of file to check. 253 @return: True if this file is a non-blacklisted python file. 254 """ 255 file_path = os.path.abspath(file_path) 256 if file_path.endswith('.py'): 257 return all(not fnmatch.fnmatch(file_path, '*' + pattern) 258 for pattern in BLACKLIST) 259 return False 260 261 262 def check_file(file_path, base_opts): 263 """ 264 Invokes pylint on files after confirming that they're not black listed. 265 266 @param base_opts: pylint base options. 267 @param file_path: path to the file we need to run pylint on. 268 """ 269 if not isinstance(file_path, basestring): 270 raise TypeError('expected a string as filepath, got %s'% 271 type(file_path)) 272 273 if should_check_file(file_path): 274 pylint_runner = pylint.lint.Run(base_opts + [file_path], exit=False) 275 if pylint_runner.linter.msg_status: 276 pylint_error(pylint_runner.linter.msg_status) 277 278 279 def visit(arg, dirname, filenames): 280 """ 281 Visit function invoked in check_dir. 282 283 @param arg: arg from os.walk.path 284 @param dirname: dir from os.walk.path 285 @param filenames: files in dir from os.walk.path 286 """ 287 for filename in filenames: 288 check_file(os.path.join(dirname, filename), arg) 289 290 291 def check_dir(dir_path, base_opts): 292 """ 293 Calls visit on files in dir_path. 294 295 @param base_opts: pylint base options. 296 @param dir_path: path to directory. 297 """ 298 os.path.walk(dir_path, visit, base_opts) 299 300 301 def extend_baseopts(base_opts, new_opt): 302 """ 303 Replaces an argument in base_opts with a cmd line argument. 304 305 @param base_opts: original pylint_base_opts. 306 @param new_opt: new cmd line option. 307 """ 308 for args in base_opts: 309 if new_opt in args: 310 base_opts.remove(args) 311 base_opts.append(new_opt) 312 313 314 def get_cmdline_options(args_list, pylint_base_opts, rcfile): 315 """ 316 Parses args_list and extends pylint_base_opts. 317 318 Command line arguments might include options mixed with files. 319 Go through this list and filter out the options, if the options are 320 specified in the pylintrc file we cannot replace them and the file 321 needs to be edited. If the options are already a part of 322 pylint_base_opts we replace them, and if not we append to 323 pylint_base_opts. 324 325 @param args_list: list of files/pylint args passed in through argv. 326 @param pylint_base_opts: default pylint options. 327 @param rcfile: text from pylint_rc. 328 """ 329 for args in args_list: 330 if args.startswith('--'): 331 opt_name = args[2:].split('=')[0] 332 if opt_name in rcfile and pylint_version >= 0.21: 333 raise run_pylint_error('The rcfile already contains the %s ' 334 'option. Please edit pylintrc instead.' 335 % opt_name) 336 else: 337 extend_baseopts(pylint_base_opts, args) 338 args_list.remove(args) 339 340 341 def git_show_to_temp_file(commit, original_file, new_temp_file): 342 """ 343 'Git shows' the file in original_file to a tmp file with 344 the name new_temp_file. We need to preserve the filename 345 as it gets reflected in pylints error report. 346 347 @param commit: commit hash of the commit we're running repo upload on. 348 @param original_file: the path to the original file we'd like to run 349 'git show' on. 350 @param new_temp_file: new_temp_file is the path to a temp file we write the 351 output of 'git show' into. 352 """ 353 git_repo = revision_control.GitRepo(common.autotest_dir, None, None, 354 common.autotest_dir) 355 356 with open(new_temp_file, 'w') as f: 357 output = git_repo.gitcmd('show --no-ext-diff %s:%s' 358 % (commit, original_file), 359 ignore_status=False).stdout 360 f.write(output) 361 362 363 def check_committed_files(work_tree_files, commit, pylint_base_opts): 364 """ 365 Get a list of files corresponding to the commit hash. 366 367 The contents of a file in the git work tree can differ from the contents 368 of a file in the commit we mean to upload. To work around this we run 369 pylint on a temp file into which we've 'git show'n the committed version 370 of each file. 371 372 @param work_tree_files: list of files in this commit specified by their 373 absolute path. 374 @param commit: hash of the commit this upload applies to. 375 @param pylint_base_opts: a list of pylint config options. 376 """ 377 files_to_check = filter(should_check_file, work_tree_files) 378 379 # Map the absolute path of each file so it's relative to the autotest repo. 380 # All files that are a part of this commit should have an abs path within 381 # the autotest repo, so this regex should never fail. 382 work_tree_files = [re.search(r'%s/(.*)' % common.autotest_dir, f).group(1) 383 for f in files_to_check] 384 385 tempdir = None 386 try: 387 tempdir = autotemp.tempdir() 388 temp_files = [os.path.join(tempdir.name, file_path.split('/')[-1:][0]) 389 for file_path in work_tree_files] 390 391 for file_tuple in zip(work_tree_files, temp_files): 392 git_show_to_temp_file(commit, *file_tuple) 393 # Only check if we successfully git showed all files in the commit. 394 batch_check_files(temp_files, pylint_base_opts) 395 finally: 396 if tempdir: 397 tempdir.clean() 398 399 400 def main(): 401 """Main function checks each file in a commit for pylint violations.""" 402 403 # For now all error/warning/refactor/convention exceptions except those in 404 # the enable string are disabled. 405 # W0611: All imported modules (except common) need to be used. 406 # W1201: Logging methods should take the form 407 # logging.<loggingmethod>(format_string, format_args...); and not 408 # logging.<loggingmethod>(format_string % (format_args...)) 409 # C0111: Docstring needed. Also checks @param for each arg. 410 # C0112: Non-empty Docstring needed. 411 # Ideally we would like to enable as much as we can, but if we did so at 412 # this stage anyone who makes a tiny change to a file will be tasked with 413 # cleaning all the lint in it. See chromium-os:37364. 414 415 # Note: 416 # 1. There are three major sources of E1101/E1103/E1120 false positives: 417 # * common_lib.enum.Enum objects 418 # * DB model objects (scheduler models are the worst, but Django models 419 # also generate some errors) 420 # 2. Docstrings are optional on private methods, and any methods that begin 421 # with either 'set_' or 'get_'. 422 pylint_rc = os.path.join(os.path.dirname(os.path.abspath(__file__)), 423 'pylintrc') 424 425 no_docstring_rgx = r'((_.*)|(set_.*)|(get_.*))' 426 if pylint_version >= 0.21: 427 pylint_base_opts = ['--rcfile=%s' % pylint_rc, 428 '--reports=no', 429 '--disable=W,R,E,C,F', 430 '--enable=W0611,W1201,C0111,C0112,E0602,W0601', 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 batch_check_files(args_list, pylint_base_opts) 450 elif os.environ.get('PRESUBMIT_FILES') is not None: 451 check_committed_files( 452 os.environ.get('PRESUBMIT_FILES').split('\n'), 453 os.environ.get('PRESUBMIT_COMMIT'), 454 pylint_base_opts) 455 else: 456 check_dir('.', pylint_base_opts) 457 458 459 if __name__ == '__main__': 460 try: 461 main() 462 except Exception as e: 463 logging.error(e) 464 sys.exit(1) 465