Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1"""Pylint plugin for py.test""" 

2from __future__ import absolute_import, print_function, unicode_literals 

3import re 

4from os import sep 

5from os.path import exists, join, dirname 

6import sys 

7from six.moves.configparser import ( # pylint: disable=import-error 

8 ConfigParser, 

9 NoSectionError, 

10 NoOptionError 

11) 

12 

13from pylint import lint 

14from pylint.config import PYLINTRC 

15from pylint.interfaces import IReporter 

16from pylint.reporters import BaseReporter 

17import pytest 

18 

19HISTKEY = 'pylint/mtimes' 

20 

21 

22class PyLintException(Exception): 

23 """Exception to raise if a file has a specified pylint error""" 

24 

25 

26class ProgrammaticReporter(BaseReporter): 

27 """Reporter that replaces output with storage in list of dictionaries""" 

28 

29 __implements__ = IReporter 

30 extension = 'prog' 

31 

32 def __init__(self, output=None): 

33 BaseReporter.__init__(self, output) 

34 self.current_module = None 

35 self.data = [] 

36 

37 def add_message(self, msg_id, location, msg): 

38 """Deprecated, but required""" 

39 raise NotImplementedError 

40 

41 def handle_message(self, msg): 

42 """Get message and append to our data structure""" 

43 self.data.append(msg) 

44 

45 def _display(self, layout): 

46 """launch layouts display""" 

47 

48 def on_set_current_module(self, module, filepath): 

49 """Hook called when a module starts to be analysed.""" 

50 print('.', end='') 

51 sys.stdout.flush() 

52 

53 def on_close(self, stats, previous_stats): 

54 """Hook called when all modules finished analyzing.""" 

55 # print a new line when pylint is finished 

56 print('') 

57 

58 

59def get_rel_path(path, parent_path): 

60 """ 

61 Give the path to object relative to ``parent_path``. 

62 """ 

63 replaced_path = path.replace(parent_path, '', 1) 

64 if replaced_path[0] == sep: 

65 rel_path = replaced_path[1:] 

66 else: 

67 rel_path = replaced_path 

68 return rel_path 

69 

70 

71def pytest_addoption(parser): 

72 """Add all our command line options""" 

73 group = parser.getgroup("general") 

74 group.addoption( 

75 "--pylint", 

76 action="store_true", default=False, 

77 help="run pylint on all" 

78 ) 

79 group.addoption( 

80 "--no-pylint", 

81 action="store_true", default=False, 

82 help="disable running pylint " 

83 ) 

84 

85 group.addoption( 

86 '--pylint-rcfile', 

87 default=None, 

88 help='Location of RC file if not pylintrc' 

89 ) 

90 group.addoption( 

91 '--pylint-error-types', 

92 default='CRWEF', 

93 help='The types of pylint errors to consider failures by letter' 

94 ', default is all of them (CRWEF).' 

95 ) 

96 group.addoption( 

97 '--pylint-jobs', 

98 default=None, 

99 help='Specify number of processes to use for pylint' 

100 ) 

101 

102 

103def pytest_sessionstart(session): 

104 """Storing pylint settings on the session""" 

105 session.pylint_files = set() 

106 session.pylint_messages = {} 

107 session.pylint_config = None 

108 session.pylintrc_file = None 

109 session.pylint_ignore = [] 

110 session.pylint_ignore_patterns = [] 

111 session.pylint_msg_template = None 

112 config = session.config 

113 

114 # Find pylintrc to check ignore list 

115 pylintrc_file = config.option.pylint_rcfile or PYLINTRC 

116 

117 if pylintrc_file and not exists(pylintrc_file): 

118 # The directory of pytest.ini got a chance 

119 pylintrc_file = join(dirname(str(config.inifile)), pylintrc_file) 

120 

121 if pylintrc_file and exists(pylintrc_file): 

122 session.pylintrc_file = pylintrc_file 

123 session.pylint_config = ConfigParser() 

124 session.pylint_config.read(pylintrc_file) 

125 

126 try: 

127 ignore_string = session.pylint_config.get('MASTER', 'ignore') 

128 if ignore_string: 

129 session.pylint_ignore = ignore_string.split(',') 

130 except (NoSectionError, NoOptionError): 

131 pass 

132 

133 try: 

134 session.pylint_ignore_patterns = session.pylint_config.get( 

135 'MASTER', 'ignore-patterns') 

136 except (NoSectionError, NoOptionError): 

137 pass 

138 

139 try: 

140 session.pylint_msg_template = session.pylint_config.get( 

141 'REPORTS', 'msg-template' 

142 ) 

143 except (NoSectionError, NoOptionError): 

144 pass 

145 

146 

147def include_file(path, ignore_list, ignore_patterns=None): 

148 """Checks if a file should be included in the collection.""" 

149 if ignore_patterns: 

150 for pattern in ignore_patterns: 

151 if re.match(pattern, path): 

152 return False 

153 parts = path.split(sep) 

154 return not set(parts) & set(ignore_list) 

155 

156 

157def pytest_configure(config): 

158 """ 

159 Add a plugin to cache file mtimes. 

160 

161 :param _pytest.config.Config config: pytest config object 

162 """ 

163 if config.option.pylint: 

164 config.pylint = PylintPlugin(config) 

165 config.pluginmanager.register(config.pylint) 

166 config.addinivalue_line('markers', "pylint: Tests which run pylint.") 

167 

168 

169class PylintPlugin(object): 

170 """ 

171 A Plugin object for pylint, which loads and records file mtimes. 

172 """ 

173 # pylint: disable=too-few-public-methods 

174 

175 def __init__(self, config): 

176 if hasattr(config, 'cache'): 

177 self.mtimes = config.cache.get(HISTKEY, {}) 

178 else: 

179 self.mtimes = {} 

180 

181 def pytest_sessionfinish(self, session): 

182 """ 

183 Save file mtimes to pytest cache. 

184 

185 :param _pytest.main.Session session: the pytest session object 

186 """ 

187 if hasattr(session.config, 'cache'): 

188 session.config.cache.set(HISTKEY, self.mtimes) 

189 

190 

191def pytest_collect_file(path, parent): 

192 """Collect files on which pylint should run""" 

193 config = parent.session.config 

194 if not config.option.pylint or config.option.no_pylint: 

195 return None 

196 if path.ext != ".py": 

197 return None 

198 rel_path = get_rel_path(path.strpath, parent.session.fspath.strpath) 

199 session = parent.session 

200 if session.pylint_config is None: 

201 # No pylintrc, therefore no ignores, so return the item. 

202 item = PyLintItem(path, parent) 

203 elif include_file(rel_path, session.pylint_ignore, 

204 session.pylint_ignore_patterns): 

205 item = PyLintItem( 

206 path, parent, session.pylint_msg_template, session.pylintrc_file 

207 ) 

208 else: 

209 return None 

210 if not item.should_skip: 

211 session.pylint_files.add(rel_path) 

212 return item 

213 

214 

215def pytest_collection_finish(session): 

216 """Lint collected files and store messages on session.""" 

217 if not session.pylint_files: 

218 return 

219 

220 jobs = session.config.option.pylint_jobs 

221 reporter = ProgrammaticReporter() 

222 # Build argument list for pylint 

223 args_list = list(session.pylint_files) 

224 if session.pylintrc_file: 

225 args_list.append('--rcfile={0}'.format( 

226 session.pylintrc_file 

227 )) 

228 if jobs is not None: 

229 args_list.append('-j') 

230 args_list.append(jobs) 

231 print('-' * 65) 

232 print('Linting files') 

233 # Disabling keyword arg to handle both 1.x and 2.x pylint API calls 

234 # pylint: disable=unexpected-keyword-arg 

235 

236 # Run pylint over the collected files. 

237 try: 

238 result = lint.Run(args_list, reporter=reporter, exit=False) 

239 except TypeError: # Handle pylint 2.0 API 

240 result = lint.Run(args_list, reporter=reporter, do_exit=False) 

241 except RuntimeError: 

242 return 

243 messages = result.linter.reporter.data 

244 # Stores the messages in a dictionary for lookup in tests. 

245 for message in messages: 

246 if message.path not in session.pylint_messages: 

247 session.pylint_messages[message.path] = [] 

248 session.pylint_messages[message.path].append(message) 

249 print('-' * 65) 

250 

251 

252class PyLintItem(pytest.Item, pytest.File): 

253 """pylint test running class.""" 

254 # pylint doesn't deal well with dynamic modules and there isn't an 

255 # astng plugin for pylint in pypi yet, so we'll have to disable 

256 # the checks. 

257 # pylint: disable=no-member,abstract-method 

258 def __init__(self, fspath, parent, msg_format=None, pylintrc_file=None): 

259 super(PyLintItem, self).__init__(fspath, parent) 

260 

261 self.add_marker("pylint") 

262 self.rel_path = get_rel_path( 

263 fspath.strpath, 

264 parent.session.fspath.strpath 

265 ) 

266 

267 if msg_format is None: 

268 self._msg_format = '{C}:{line:3d},{column:2d}: {msg} ({symbol})' 

269 else: 

270 self._msg_format = msg_format 

271 

272 self.pylintrc_file = pylintrc_file 

273 self.__mtime = self.fspath.mtime() 

274 prev_mtime = self.config.pylint.mtimes.get(self.nodeid, 0) 

275 self.should_skip = (prev_mtime == self.__mtime) 

276 

277 def setup(self): 

278 """Mark unchanged files as SKIPPED.""" 

279 if self.should_skip: 

280 pytest.skip("file(s) previously passed pylint checks") 

281 

282 def runtest(self): 

283 """Check the pylint messages to see if any errors were reported.""" 

284 reported_errors = [] 

285 for error in self.session.pylint_messages.get(self.rel_path, []): 

286 if error.C in self.config.option.pylint_error_types: 

287 reported_errors.append( 

288 error.format(self._msg_format) 

289 ) 

290 if reported_errors: 

291 raise PyLintException('\n'.join(reported_errors)) 

292 

293 # Update the cache if the item passed pylint. 

294 self.config.pylint.mtimes[self.nodeid] = self.__mtime 

295 

296 def repr_failure(self, excinfo): 

297 """Handle any test failures by checkint that they were ours.""" 

298 if excinfo.errisinstance(PyLintException): 

299 return excinfo.value.args[0] 

300 return super(PyLintItem, self).repr_failure(excinfo) 

301 

302 def reportinfo(self): 

303 """Generate our test report""" 

304 return self.fspath, None, "[pylint] {0}".format(self.rel_path)