Coverage for pyquickhelper/pycode/utils_tests_helper.py: 92%

184 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-06-03 02:21 +0200

1""" 

2@file 

3@brief This extension contains various functionalities to help unittesting. 

4""" 

5import os 

6import stat 

7import sys 

8import re 

9import warnings 

10import time 

11import importlib 

12from contextlib import redirect_stdout, redirect_stderr 

13from io import StringIO 

14 

15 

16def _get_PyLinterRunV(): 

17 # Separate function to speed up import. 

18 from pylint.lint import Run as PyLinterRun 

19 from pylint import __version__ as pylint_version 

20 if pylint_version >= '2.0.0': 

21 PyLinterRunV = PyLinterRun 

22 else: 

23 PyLinterRunV = lambda *args, do_exit=False: PyLinterRun( # pylint: disable=E1120, E1123 

24 *args, exit=do_exit) # pylint: disable=E1120, E1123 

25 return PyLinterRunV 

26 

27 

28def get_temp_folder(thisfile, name=None, clean=True, create=True, 

29 persistent=False, path_name="tpath"): 

30 """ 

31 Creates and returns a local temporary folder to store files 

32 when unit testing. 

33 

34 @param thisfile use ``__file__`` or the function which runs the test 

35 @param name name of the temporary folder 

36 @param clean if True, clean the folder first, it can also a function 

37 called to determine whether or not the folder should be 

38 cleaned 

39 @param create if True, creates it (empty if clean is True) 

40 @param persistent if True, create a folder at root level to reduce path length, 

41 the function checks the ``MAX_PATH`` variable and 

42 shorten the test folder is *max_path* is True on :epkg:`Windows`, 

43 on :epkg:`Linux`, it creates a folder three level ahead 

44 @param path_name test path used when *max_path* is True 

45 @return temporary folder 

46 

47 The function extracts the file which runs this test and will name 

48 the temporary folder base on the name of the method. *name* must be None. 

49 

50 Parameter *clean* can be a function. 

51 Signature is ``def clean(folder)``. 

52 """ 

53 if name is None: 

54 name = thisfile.__name__ 

55 if name.startswith("test_"): 

56 name = "temp_" + name[5:] 

57 elif not name.startswith("temp_"): 

58 name = "temp_" + name 

59 thisfile = os.path.abspath(thisfile.__func__.__code__.co_filename) 

60 final = os.path.split(name)[-1] 

61 

62 if not final.startswith("temp_") and not final.startswith("temp2_"): 

63 raise NameError( # pragma: no cover 

64 f"the folder '{name}' must begin with temp_") 

65 

66 local = os.path.join( 

67 os.path.normpath(os.path.abspath(os.path.dirname(thisfile))), name) 

68 

69 if persistent: 

70 if sys.platform.startswith("win"): # pragma: no cover 

71 from ctypes.wintypes import MAX_PATH 

72 if MAX_PATH <= 300: 

73 local = os.path.join(os.path.abspath("\\" + path_name), name) 

74 else: 

75 local = os.path.join( 

76 local, "..", "..", "..", "..", path_name, name) 

77 else: 

78 local = os.path.join(local, "..", "..", "..", 

79 "..", path_name, name) 

80 local = os.path.normpath(local) 

81 

82 if name == local: 

83 raise NameError( # pragma: no cover 

84 f"The folder '{name}' must be relative, not absolute") 

85 

86 if not os.path.exists(local): 

87 if create: 

88 os.makedirs(local) 

89 mode = os.stat(local).st_mode 

90 nmode = mode | stat.S_IWRITE 

91 if nmode != mode: 

92 os.chmod(local, nmode) # pragma: no cover 

93 else: 

94 if (callable(clean) and clean(local)) or (not callable(clean) and clean): 

95 # delayed import to speed up import time of pycode 

96 from ..filehelper.synchelper import remove_folder 

97 remove_folder(local) 

98 time.sleep(0.1) 

99 if create and not os.path.exists(local): 

100 os.makedirs(local) 

101 mode = os.stat(local).st_mode 

102 nmode = mode | stat.S_IWRITE 

103 if nmode != mode: 

104 os.chmod(local, nmode) # pragma: no cover 

105 

106 return local 

107 

108 

109def _extended_refactoring(filename, line): # pragma: no cover 

110 """ 

111 Private function which does extra checkings 

112 when refactoring :epkg:`pyquickhelper`. 

113 

114 @param filename filename 

115 @param line line 

116 @return None or error message 

117 """ 

118 if "from pyquickhelper import fLOG" in line: 

119 if "test_code_style" not in filename: 

120 return "issue with fLOG" 

121 if "from pyquickhelper import noLOG" in line: 

122 if "test_code_style" not in filename: 

123 return "issue with noLOG" 

124 if "from pyquickhelper import run_cmd" in line: 

125 if "test_code_style" not in filename: 

126 return "issue with run_cmd" 

127 if "from pyquickhelper import get_temp_folder" in line: 

128 if "test_code_style" not in filename: 

129 return "issue with get_temp_folder" 

130 return None 

131 

132 

133class PEP8Exception(Exception): 

134 """ 

135 Code or style issues. 

136 """ 

137 pass 

138 

139 

140def check_pep8(folder, ignore=('E265', 'W504'), skip=None, 

141 complexity=-1, stop_after=100, fLOG=None, 

142 pylint_ignore=('C0103', 'C1801', 

143 'R1705', 

144 'W0108', 'W0613', 

145 'W0107', 'C0415', 

146 'C0209'), 

147 recursive=True, neg_pattern=None, extended=None, 

148 max_line_length=143, pattern=".*[.]py$", 

149 run_lint=True, verbose=False, run_cmd_filter=None): 

150 """ 

151 Checks if :epkg:`PEP8`, 

152 the function calls command :epkg:`pycodestyle` 

153 on a specific folder. 

154 

155 @param folder folder to look into 

156 @param ignore list of warnings to skip when raising an exception if 

157 :epkg:`PEP8` is not verified, see also 

158 `Error Codes <http://pep8.readthedocs.org/en/latest/intro.html#error-codes>`_ 

159 @param pylint_ignore ignore :epkg:`pylint` issues, see 

160 :epkg:`pylint error codes` 

161 @param complexity see `check_file <https://pycodestyle.pycqa.org/en/latest/api.html>`_ 

162 @param stop_after stop after *stop_after* issues 

163 @param skip skip a warning if a substring in this list is found 

164 @param neg_pattern skip files verifying this regular expressions 

165 @param extended list of tuple (name, function), see below 

166 @param max_line_length maximum allowed length of a line of code 

167 @param recursive look into subfolder 

168 @param pattern only file matching this pattern will be checked 

169 @param run_lint run :epkg:`pylint` 

170 @param verbose :epkg:`pylint` is slow, tells which file is 

171 investigated (but it is even slower) 

172 @param run_cmd_filter some files makes :epkg:`pylint` crashes (``import yaml``), 

173 the test for this is run in a separate process 

174 if the function *run_cmd_filter* returns True of the filename, 

175 *verbose* is set to True in that case 

176 @param fLOG logging function 

177 @return output 

178 

179 Functions mentioned in *extended* takes two parameters (file name and line) 

180 and they returned None or an error message or a tuple (position in the line, error message). 

181 When the return is not empty, a warning will be added to the ones 

182 printed by :epkg:`pycodestyle`. 

183 A few codes to ignore: 

184 

185 * *E501*: line too long (?? characters) 

186 * *E265*: block comments should have a space after # 

187 * *W504*: line break after binary operator, this one is raised 

188 after the code is modified by @see fn remove_extra_spaces_and_pep8. 

189 

190 The full list is available at :epkg:`PEP8 codes`. In addition, 

191 the function adds its own codes: 

192 

193 * *ECL1*: line too long for a specific reason. 

194 

195 Some errors to disable with :epkg:`pylint`: 

196 

197 * *C0103*: variable name is not conform 

198 * *C0111*: missing function docstring 

199 * *C1801*: do not use `len(SEQUENCE)` to determine if a sequence is empty 

200 * *R0205*: Class '?' inherits from object, can be safely removed from bases in python3 (pylint) 

201 * *R0901*: too many ancestors 

202 * *R0902*: too many instance attributes 

203 * *R0911*: too many return statements 

204 * *R0912*: too many branches 

205 * *R0913*: too many arguments 

206 * *R0914*: too many local variables 

207 * *R0915*: too many statements 

208 * *R1702*: too many nested blocks 

209 * *R1705*: unnecessary "else" after "return" 

210 * *W0107*: unnecessary pass statements 

211 * *W0108*: Lambda may not be necessary 

212 * *W0613*: unused argument 

213 

214 The full list is available at :epkg:`pylint error codes`. 

215 :epkg:`pylint` was added used to check the code. 

216 It produces the following list of errors 

217 :epkg:`pylint error codes`. 

218 

219 If *neg_pattern* is empty, it populates with a default value 

220 which skips unnecessary folders: 

221 ``".*[/\\\\\\\\]((_venv)|([.]git)|(__pycache__)|(temp_)).*"``. 

222 """ 

223 # delayed import to speed up import time of pycode 

224 import pycodestyle 

225 from ..filehelper.synchelper import explore_folder_iterfile 

226 if fLOG is None: 

227 from ..loghelper.flog import noLOG # pragma: no cover 

228 fLOG = noLOG # pragma: no cover 

229 

230 def extended_checkings(fname, content, buf, extended): 

231 for i, line in enumerate(content): 

232 for name, fu in extended: 

233 r = fu(fname, line) 

234 if isinstance(r, tuple): 

235 c, r = r 

236 else: 

237 c = 1 

238 if r is not None: 

239 buf.write(f"{fname}:{i + 1}:{c} F{name} {r}\n") 

240 

241 def fkeep(s): 

242 if len(s) == 0: 

243 return False 

244 if skip is not None: 

245 for kip in skip: 

246 if kip in s: 

247 return False 

248 return True 

249 

250 if max_line_length is not None: 

251 if extended is None: 

252 extended = [] 

253 else: 

254 extended = extended.copy() 

255 

256 def check_lenght_line(fname, line): 

257 if len(line) > max_line_length and not line.lstrip().startswith('#'): 

258 if ">`_" in line: 

259 return f"line too long (link) {len(line)} > {max_line_length}" 

260 if ":math:`" in line: 

261 return "line too long (:math:) {0} > {1}".format( # pragma: no cover 

262 len(line), max_line_length) 

263 if "ERROR: " in line: 

264 return "line too long (ERROR:) {0} > {1}".format( # pragma: no cover 

265 len(line), max_line_length) 

266 return None 

267 

268 extended.append(("[ECL1]", check_lenght_line)) 

269 

270 if ignore is None: 

271 ignore = tuple() 

272 elif isinstance(ignore, list): 

273 ignore = tuple(ignore) 

274 

275 if neg_pattern is None: 

276 neg_pattern = ".*[/\\\\]((_venv)|([.]git)|(__pycache__)|(temp_)|([.]egg)|(bin)).*" 

277 

278 try: 

279 regneg_filter = None if neg_pattern is None else re.compile( 

280 neg_pattern) 

281 except re.error as e: # pragma: no cover 

282 raise ValueError(f"Unable to compile '{neg_pattern}'") from e 

283 

284 # pycodestyle 

285 fLOG(f"[check_pep8] code style on '{folder}'") 

286 files_to_check = [] 

287 skipped = [] 

288 buf = StringIO() 

289 with redirect_stdout(buf): 

290 for file in explore_folder_iterfile(folder, pattern=pattern, 

291 recursive=recursive): 

292 if regneg_filter is not None: 

293 if regneg_filter.search(file): 

294 skipped.append(file) 

295 continue 

296 if file.endswith("__init__.py"): 

297 ig = ignore + ('F401',) 

298 else: 

299 ig = ignore 

300 if file is None: 

301 raise RuntimeError( # pragma: no cover 

302 "file cannot be None") 

303 if len(file) == 0: 

304 raise RuntimeError( # pragma: no cover 

305 "file cannot be empty") 

306 

307 # code style 

308 files_to_check.append(file) 

309 try: 

310 style = pycodestyle.StyleGuide( 

311 ignore=ig, complexity=complexity, format='pylint', 

312 max_line_length=max_line_length) 

313 res = style.check_files([file]) 

314 except TypeError as e: # pragma: no cover 

315 ext = "This is often due to an instruction from . import... The imported module has no name." 

316 raise TypeError("Issue with pycodesyle for module '{0}' ig={1} complexity={2}\n{3}".format( 

317 file, ig, complexity, ext)) from e 

318 

319 if extended is not None: 

320 with open(file, "r", errors="ignore") as f: 

321 content = f.readlines() 

322 extended_checkings(file, content, buf, extended) 

323 

324 if res.total_errors + res.file_errors > 0: 

325 res.print_filename = True 

326 lines = [_ for _ in buf.getvalue().split("\n") if fkeep(_)] 

327 if len(lines) > stop_after: 

328 raise PEP8Exception( # pragma: no cover 

329 "{0} lines\n{1}".format(len(lines), "\n".join(lines))) 

330 

331 lines = [_ for _ in buf.getvalue().split("\n") if fkeep(_)] 

332 if len(lines) > 10: 

333 raise PEP8Exception( # pragma: no cover 

334 "{0} lines\n{1}".format(len(lines), "\n".join(lines))) 

335 

336 if len(files_to_check) == 0: 

337 mes = skipped[0] if skipped else "-no skipped file-" 

338 raise FileNotFoundError( # pragma: no cover 

339 f"No file found in '{folder}'\n pattern='{pattern}'\nskipped='{mes}'") 

340 

341 # pylint 

342 if not run_lint: 

343 return "\n".join(lines) 

344 fLOG(f"[check_pep8] pylint with {len(files_to_check)} files") 

345 memout = sys.stdout 

346 

347 try: 

348 fLOG('', OutputStream=memout) 

349 regular_print = False 

350 except TypeError: # pragma: no cover 

351 regular_print = True 

352 

353 def myprint(s): 

354 "local print, chooses the right function" 

355 if regular_print: # pragma: no cover 

356 memout.write(s + "\n") 

357 else: # pragma: no cover 

358 fLOG(s, OutputStream=memout) 

359 

360 neg_pat = ".*temp[0-9]?_.*,doc_.*" 

361 if neg_pattern is not None: 

362 neg_pat += ',' + neg_pattern 

363 

364 if run_cmd_filter is not None: 

365 verbose = True # pragma: no cover 

366 

367 PyLinterRunV = _get_PyLinterRunV() 

368 sout = StringIO() 

369 serr = StringIO() 

370 with redirect_stdout(sout): 

371 with redirect_stderr(serr): 

372 with warnings.catch_warnings(): 

373 warnings.simplefilter("ignore", DeprecationWarning) 

374 opt = ["--ignore-patterns=" + neg_pat, "--persistent=n", 

375 '--jobs=1', '--suggestion-mode=n', "--score=n", 

376 '--max-args=30', '--max-locals=50', '--max-returns=30', 

377 '--max-branches=50', '--max-parents=25', 

378 '--max-attributes=50', '--min-public-methods=0', 

379 '--max-public-methods=100', '--max-bool-expr=10', 

380 '--max-statements=200', 

381 '--msg-template={abspath}:{line}: {msg_id}: {msg} (pylint)'] 

382 if pylint_ignore: 

383 opt.append('--disable=' + ','.join(pylint_ignore)) 

384 if max_line_length: 

385 opt.append("--max-line-length=%d" % max_line_length) 

386 if verbose: # pragma: no cover 

387 for i, name in enumerate(files_to_check): 

388 cop = list(opt) 

389 cop.append(name) 

390 if run_cmd_filter is None or not run_cmd_filter(name): 

391 myprint( 

392 f"[check_pep8] lint file {i + 1}/{len(files_to_check)} - '{name}'\n") 

393 PyLinterRunV(cop, do_exit=False) 

394 else: 

395 # delayed import to speed up import time of pycode 

396 from ..loghelper import run_cmd 

397 # runs from command line 

398 myprint( 

399 f"[check_pep8] cmd-lint file {i + 1}/{len(files_to_check)} - '{name}'\n") 

400 cmd = "{0} -m pylint {1}".format( 

401 sys.executable, " ".join('"{0}"'.format(_) for _ in cop)) 

402 out = run_cmd(cmd, wait=True)[0] 

403 lines.extend(_ for _ in out.split( 

404 '\n') if _.strip('\r ')) 

405 else: 

406 opt.extend(files_to_check) 

407 PyLinterRunV(opt, do_exit=False) 

408 

409 pylint_lines = sout.getvalue().split('\n') 

410 pylint_lines = [ 

411 _ for _ in pylint_lines if ( 

412 '(pylint)' in _ and fkeep(_) and _[0] != ' ' and len(_.split(':')) > 2)] 

413 pylint_lines = [_ for _ in pylint_lines if not _.startswith( 

414 "except ") and not _.startswith("else:") and not _.startswith( 

415 "try:") and "# noqa" not in _] 

416 lines.extend(pylint_lines) 

417 if len(lines) > 0: 

418 raise PEP8Exception( 

419 "{0} lines\n{1}".format(len(lines), "\n".join(lines))) 

420 

421 return "\n".join(lines) 

422 

423 

424def add_missing_development_version(names, root, hide=False): 

425 """ 

426 Looks for development version of a given module and add paths to 

427 ``sys.path`` after having checked they are working. 

428 

429 @param names name or names of the module to import 

430 @param root folder where to look (assuming all modules location 

431 at the same place in a flat hierarchy) 

432 @param hide hide warnings when importing a module (might be a lot) 

433 @return added paths 

434 """ 

435 # delayed import to speed up import time 

436 from ..loghelper import sys_path_append 

437 

438 if not isinstance(names, list): 

439 names = [names] 

440 root = os.path.abspath(root) 

441 if os.path.isfile(root): 

442 root = os.path.dirname(root) 

443 if not os.path.exists(root): 

444 raise FileNotFoundError(root) # pragma: no cover 

445 

446 spl = os.path.split(root) 

447 py27 = False 

448 if spl[-1].startswith("ut_"): 

449 if "dist_module27" in root: 

450 # python 27 

451 py27 = True 

452 newroot = os.path.join(root, "..", "..", "..", "..") 

453 else: 

454 newroot = os.path.join(root, "..", "..", "..") 

455 else: 

456 newroot = root 

457 

458 newroot = os.path.normpath(os.path.abspath(newroot)) 

459 found = os.listdir(newroot) 

460 dirs = [os.path.join(newroot, _) for _ in found] 

461 

462 paths = [] 

463 for name in names: 

464 exc = None 

465 try: 

466 if hide: 

467 with warnings.catch_warnings(record=True): 

468 importlib.import_module(name) 

469 else: 

470 importlib.import_module(name) 

471 continue 

472 except ImportError as e: # pragma: no cover 

473 # it requires a path 

474 exc = e 

475 

476 if name not in found: 

477 raise FileNotFoundError( # pragma: no cover 

478 "Unable to find a subfolder '{0}' in '{1}' (py27={3})\nFOUND:\n{2}\nexc={4}".format( 

479 name, newroot, "\n".join(dirs), py27, exc)) 

480 

481 if py27: # pragma: no cover 

482 this = os.path.join(newroot, name, "dist_module27", "src") 

483 if not os.path.exists(this): 

484 this = os.path.join(newroot, name, "dist_module27") 

485 else: # pragma: no cover 

486 this = os.path.join(newroot, name, "src") 

487 if not os.path.exists(this): 

488 this = os.path.join(newroot, name) 

489 

490 if not os.path.exists(this): # pragma: no cover 

491 raise FileNotFoundError( 

492 "unable to find a subfolder '{0}' in '{1}' (*py27={3})\nFOUND:\n{2}".format( 

493 this, newroot, "\n".join(dirs), py27)) 

494 with sys_path_append(this): # pragma: no cover 

495 if hide: 

496 with warnings.catch_warnings(record=True): 

497 importlib.import_module(name) 

498 else: 

499 importlib.import_module(name) 

500 paths.append(this) # pragma: no cover 

501 return paths